Compare commits

...

23 Commits

Author SHA1 Message Date
2ba8c0732b Merge pull request 'feat(finance): 财务概览模块 1:1 还原' (#10) from feature/finance-overview-1to1 into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 2m21s
2026-03-05 03:12:11 +00:00
f7eba55039 feat(finance): add overview dashboard and platform fee rate 2026-03-05 10:47:15 +08:00
fdbefca650 merge: bring finance report api changes into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 26s
2026-03-04 22:03:57 +08:00
4a7d012a58 merge: bring member points mall changes into dev 2026-03-04 22:02:45 +08:00
d330db84fc Merge pull request #6 from msumshk/feature/finance-invoice-1to1
Feature/finance invoice 1to1
2026-03-04 21:23:17 +08:00
c79e9bd6e8 feat(finance): 完成发票管理模块后端实现 2026-03-04 21:13:33 +08:00
3f308c2d0c chore(docs): update submodule with finance scripts 2026-03-04 17:07:43 +08:00
5dfaac01fd feat(finance): implement invoice and business report backend modules 2026-03-04 16:57:06 +08:00
21a689edec Merge pull request #5 from msumshk/feature/finance-cost-1to1
feat(finance): add cost management backend module
2026-03-04 16:15:02 +08:00
fa6e376b86 feat(finance): add cost management backend module 2026-03-04 16:07:16 +08:00
76366cbc30 Merge pull request #4 from msumshk/feature/finance-report-1to1
feat(finance): add tenant settlement query backend
2026-03-04 16:00:02 +08:00
b0bb87d97c feat(finance): add tenant settlement query backend 2026-03-04 15:48:37 +08:00
b5aa060faf feat(member): add message reach backend module and docs seeds 2026-03-04 13:35:22 +08:00
39e28c1a62 Merge pull request #3 from msumshk/feature/member-points-mall-1to1
feat(member): implement points mall backend module
2026-03-04 12:32:58 +08:00
dd2ac79d48 Merge pull request #2 from msumshk/feature/member-message-reach-module
feat: 完成会员消息触达后端模块
2026-03-04 12:17:33 +08:00
bd418c5927 feat(member): implement points mall backend module 2026-03-04 12:15:18 +08:00
a8cfda88f7 feat: 完成会员消息触达后端模块 2026-03-04 11:53:52 +08:00
e4f7ceeaa7 Merge pull request #1 from msumshk/feature/finance-transaction-module
feat: 新增财务交易流水后端模块
2026-03-04 11:38:34 +08:00
d437b146d1 feat: 新增财务交易流水后端模块 2026-03-04 11:33:29 +08:00
2970134200 feat: implement tenant member stored card module
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m24s
2026-03-04 09:14:57 +08:00
d96ca4971a feat(member): implement member center management module
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m54s
2026-03-03 20:38:31 +08:00
c2821202c7 chore(docs): bump docs submodule for customer analysis seed 2026-03-03 16:46:22 +08:00
26afffd874 feat(customer): add customer analysis query APIs 2026-03-03 16:45:39 +08:00
319 changed files with 66913 additions and 2 deletions

View File

@@ -0,0 +1,521 @@
namespace TakeoutSaaS.TenantApi.Contracts.Customer;
/// <summary>
/// 客户分析总览请求。
/// </summary>
public sealed class CustomerAnalysisOverviewRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 统计周期7d/30d/90d/365d
/// </summary>
public string? Period { get; set; }
}
/// <summary>
/// 客群明细筛选请求。
/// </summary>
public class CustomerAnalysisSegmentFilterRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 统计周期7d/30d/90d/365d
/// </summary>
public string? Period { get; set; }
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; set; } = "all";
/// <summary>
/// 关键词(姓名/手机号)。
/// </summary>
public string? Keyword { get; set; }
}
/// <summary>
/// 客群明细分页请求。
/// </summary>
public sealed class CustomerAnalysisSegmentListRequest : CustomerAnalysisSegmentFilterRequest
{
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 会员详情请求。
/// </summary>
public sealed class CustomerMemberDetailRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 客户标识(手机号归一化)。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
}
/// <summary>
/// 客户分析导出请求。
/// </summary>
public sealed class CustomerAnalysisExportRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 统计周期7d/30d/90d/365d
/// </summary>
public string? Period { get; set; }
}
/// <summary>
/// 客户分析趋势点响应。
/// </summary>
public sealed class CustomerAnalysisTrendPointResponse
{
/// <summary>
/// 维度标签。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 数量值。
/// </summary>
public int Value { get; set; }
}
/// <summary>
/// 新老客构成项响应。
/// </summary>
public sealed class CustomerAnalysisCompositionItemResponse
{
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; set; } = string.Empty;
/// <summary>
/// 分群名称。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 人数。
/// </summary>
public int Count { get; set; }
/// <summary>
/// 占比(百分比)。
/// </summary>
public decimal Percent { get; set; }
/// <summary>
/// 色调。
/// </summary>
public string Tone { get; set; } = "blue";
}
/// <summary>
/// 客单价分布项响应。
/// </summary>
public sealed class CustomerAnalysisAmountDistributionItemResponse
{
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; set; } = string.Empty;
/// <summary>
/// 区间标签。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 人数。
/// </summary>
public int Count { get; set; }
/// <summary>
/// 占比(百分比)。
/// </summary>
public decimal Percent { get; set; }
}
/// <summary>
/// RFM 分层单元响应。
/// </summary>
public sealed class CustomerAnalysisRfmCellResponse
{
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; set; } = string.Empty;
/// <summary>
/// 标签。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 人数。
/// </summary>
public int Count { get; set; }
/// <summary>
/// 温度hot/warm/cool/cold
/// </summary>
public string Tone { get; set; } = "cold";
}
/// <summary>
/// RFM 分层行响应。
/// </summary>
public sealed class CustomerAnalysisRfmRowResponse
{
/// <summary>
/// 行标签。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 单元格集合。
/// </summary>
public List<CustomerAnalysisRfmCellResponse> Cells { get; set; } = [];
}
/// <summary>
/// 高价值客户响应。
/// </summary>
public sealed class CustomerAnalysisTopCustomerResponse
{
/// <summary>
/// 排名。
/// </summary>
public int Rank { get; set; }
/// <summary>
/// 客户标识。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; set; } = string.Empty;
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 下单次数。
/// </summary>
public int OrderCount { get; set; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; set; }
/// <summary>
/// 最近下单时间yyyy-MM-dd
/// </summary>
public string LastOrderAt { get; set; } = string.Empty;
/// <summary>
/// 客户标签。
/// </summary>
public List<CustomerTagResponse> Tags { get; set; } = [];
}
/// <summary>
/// 客户分析总览响应。
/// </summary>
public sealed class CustomerAnalysisOverviewResponse
{
/// <summary>
/// 统计周期编码。
/// </summary>
public string PeriodCode { get; set; } = "30d";
/// <summary>
/// 统计周期天数。
/// </summary>
public int PeriodDays { get; set; } = 30;
/// <summary>
/// 客户总数。
/// </summary>
public int TotalCustomers { get; set; }
/// <summary>
/// 周期新增客户数。
/// </summary>
public int NewCustomers { get; set; }
/// <summary>
/// 新增较上一周期增长百分比。
/// </summary>
public decimal GrowthRatePercent { get; set; }
/// <summary>
/// 周期内日均新增客户。
/// </summary>
public decimal NewCustomersDailyAverage { get; set; }
/// <summary>
/// 活跃客户数。
/// </summary>
public int ActiveCustomers { get; set; }
/// <summary>
/// 活跃率(百分比)。
/// </summary>
public decimal ActiveRatePercent { get; set; }
/// <summary>
/// 平均客户价值。
/// </summary>
public decimal AverageLifetimeValue { get; set; }
/// <summary>
/// 客户增长趋势。
/// </summary>
public List<CustomerAnalysisTrendPointResponse> GrowthTrend { get; set; } = [];
/// <summary>
/// 新老客占比。
/// </summary>
public List<CustomerAnalysisCompositionItemResponse> Composition { get; set; } = [];
/// <summary>
/// 客单价分布。
/// </summary>
public List<CustomerAnalysisAmountDistributionItemResponse> AmountDistribution { get; set; } = [];
/// <summary>
/// RFM 分层。
/// </summary>
public List<CustomerAnalysisRfmRowResponse> RfmRows { get; set; } = [];
/// <summary>
/// 高价值客户 Top10。
/// </summary>
public List<CustomerAnalysisTopCustomerResponse> TopCustomers { get; set; } = [];
}
/// <summary>
/// 客群明细行响应。
/// </summary>
public sealed class CustomerAnalysisSegmentListItemResponse
{
/// <summary>
/// 客户标识。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; set; } = string.Empty;
/// <summary>
/// 头像文案。
/// </summary>
public string AvatarText { get; set; } = string.Empty;
/// <summary>
/// 头像颜色。
/// </summary>
public string AvatarColor { get; set; } = string.Empty;
/// <summary>
/// 客户标签。
/// </summary>
public List<CustomerTagResponse> Tags { get; set; } = [];
/// <summary>
/// 是否会员。
/// </summary>
public bool IsMember { get; set; }
/// <summary>
/// 会员等级。
/// </summary>
public string MemberTierName { get; set; } = string.Empty;
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 下单次数。
/// </summary>
public int OrderCount { get; set; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; set; }
/// <summary>
/// 注册时间yyyy-MM-dd
/// </summary>
public string RegisteredAt { get; set; } = string.Empty;
/// <summary>
/// 最近下单时间yyyy-MM-dd
/// </summary>
public string LastOrderAt { get; set; } = string.Empty;
/// <summary>
/// 是否弱化显示。
/// </summary>
public bool IsDimmed { get; set; }
}
/// <summary>
/// 客群明细分页响应。
/// </summary>
public sealed class CustomerAnalysisSegmentListResultResponse
{
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; set; } = string.Empty;
/// <summary>
/// 分群标题。
/// </summary>
public string SegmentTitle { get; set; } = string.Empty;
/// <summary>
/// 分群说明。
/// </summary>
public string SegmentDescription { get; set; } = string.Empty;
/// <summary>
/// 列表项。
/// </summary>
public List<CustomerAnalysisSegmentListItemResponse> 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 CustomerMemberDetailResponse
{
/// <summary>
/// 客户标识。
/// </summary>
public string CustomerKey { get; set; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; set; } = string.Empty;
/// <summary>
/// 来源。
/// </summary>
public string Source { get; set; } = string.Empty;
/// <summary>
/// 注册时间yyyy-MM-dd
/// </summary>
public string RegisteredAt { get; set; } = string.Empty;
/// <summary>
/// 最近下单时间yyyy-MM-dd
/// </summary>
public string LastOrderAt { get; set; } = string.Empty;
/// <summary>
/// 会员摘要。
/// </summary>
public CustomerMemberSummaryResponse Member { get; set; } = new();
/// <summary>
/// 客户标签。
/// </summary>
public List<CustomerTagResponse> Tags { get; set; } = [];
/// <summary>
/// 累计下单次数。
/// </summary>
public int TotalOrders { get; set; }
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; set; }
/// <summary>
/// 复购率(百分比)。
/// </summary>
public decimal RepurchaseRatePercent { get; set; }
/// <summary>
/// 最近订单。
/// </summary>
public List<CustomerRecentOrderResponse> RecentOrders { get; set; } = [];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,329 @@
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
/// <summary>
/// 交易流水筛选请求。
/// </summary>
public class FinanceTransactionFilterRequest
{
/// <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>
/// 交易类型income/refund/stored_card_recharge/point_redeem
/// </summary>
public string? Type { get; set; }
/// <summary>
/// 渠道delivery/pickup/dine_in
/// </summary>
public string? Channel { get; set; }
/// <summary>
/// 支付方式wechat/alipay/cash/card/balance
/// </summary>
public string? PaymentMethod { get; set; }
/// <summary>
/// 关键词(流水号/订单号)。
/// </summary>
public string? Keyword { get; set; }
}
/// <summary>
/// 交易流水列表请求。
/// </summary>
public sealed class FinanceTransactionListRequest : FinanceTransactionFilterRequest
{
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 20;
}
/// <summary>
/// 交易流水详情请求。
/// </summary>
public sealed class FinanceTransactionDetailRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 交易标识sourceType:sourceId
/// </summary>
public string TransactionId { get; set; } = string.Empty;
}
/// <summary>
/// 交易流水列表结果。
/// </summary>
public sealed class FinanceTransactionListResultResponse
{
/// <summary>
/// 列表。
/// </summary>
public List<FinanceTransactionListItemResponse> 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 decimal PageIncomeAmount { get; set; }
/// <summary>
/// 本页退款。
/// </summary>
public decimal PageRefundAmount { get; set; }
}
/// <summary>
/// 交易流水行。
/// </summary>
public sealed class FinanceTransactionListItemResponse
{
/// <summary>
/// 交易标识。
/// </summary>
public string TransactionId { get; set; } = string.Empty;
/// <summary>
/// 流水号。
/// </summary>
public string TransactionNo { get; set; } = string.Empty;
/// <summary>
/// 关联订单号。
/// </summary>
public string? OrderNo { get; set; }
/// <summary>
/// 类型编码。
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// 类型文案。
/// </summary>
public string TypeText { get; set; } = string.Empty;
/// <summary>
/// 渠道文案。
/// </summary>
public string Channel { get; set; } = string.Empty;
/// <summary>
/// 支付方式文案。
/// </summary>
public string PaymentMethod { get; set; } = string.Empty;
/// <summary>
/// 交易金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 交易时间。
/// </summary>
public string OccurredAt { get; set; } = string.Empty;
/// <summary>
/// 备注。
/// </summary>
public string Remark { get; set; } = string.Empty;
/// <summary>
/// 是否收入。
/// </summary>
public bool IsIncome { get; set; }
}
/// <summary>
/// 交易流水统计结果。
/// </summary>
public sealed class FinanceTransactionStatsResponse
{
/// <summary>
/// 总收入。
/// </summary>
public decimal TotalIncome { get; set; }
/// <summary>
/// 总退款。
/// </summary>
public decimal TotalRefund { get; set; }
/// <summary>
/// 总笔数。
/// </summary>
public int TotalCount { get; set; }
}
/// <summary>
/// 交易流水详情。
/// </summary>
public sealed class FinanceTransactionDetailResponse
{
/// <summary>
/// 交易标识。
/// </summary>
public string TransactionId { get; set; } = string.Empty;
/// <summary>
/// 流水号。
/// </summary>
public string TransactionNo { get; set; } = string.Empty;
/// <summary>
/// 类型编码。
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// 类型文案。
/// </summary>
public string TypeText { get; set; } = string.Empty;
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 关联订单号。
/// </summary>
public string? OrderNo { get; set; }
/// <summary>
/// 渠道文案。
/// </summary>
public string Channel { get; set; } = string.Empty;
/// <summary>
/// 支付方式文案。
/// </summary>
public string PaymentMethod { get; set; } = string.Empty;
/// <summary>
/// 交易金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 交易时间。
/// </summary>
public string OccurredAt { get; set; } = string.Empty;
/// <summary>
/// 备注。
/// </summary>
public string Remark { get; set; } = string.Empty;
/// <summary>
/// 顾客姓名。
/// </summary>
public string CustomerName { get; set; } = string.Empty;
/// <summary>
/// 顾客手机号。
/// </summary>
public string CustomerPhone { get; set; } = string.Empty;
/// <summary>
/// 退款单号。
/// </summary>
public string? RefundNo { get; set; }
/// <summary>
/// 退款原因。
/// </summary>
public string? RefundReason { get; set; }
/// <summary>
/// 会员名称。
/// </summary>
public string? MemberName { get; set; }
/// <summary>
/// 会员手机号。
/// </summary>
public string? MemberMobileMasked { get; set; }
/// <summary>
/// 充值金额。
/// </summary>
public decimal? RechargeAmount { get; set; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal? GiftAmount { get; set; }
/// <summary>
/// 到账金额。
/// </summary>
public decimal? ArrivedAmount { get; set; }
/// <summary>
/// 积分变动值。
/// </summary>
public int? PointChangeAmount { get; set; }
/// <summary>
/// 积分变动后余额。
/// </summary>
public int? PointBalanceAfterChange { get; set; }
}
/// <summary>
/// 交易流水导出结果。
/// </summary>
public sealed class FinanceTransactionExportResponse
{
/// <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; }
}

View File

@@ -0,0 +1,346 @@
namespace TakeoutSaaS.TenantApi.Contracts.Member;
/// <summary>
/// 会员列表筛选请求。
/// </summary>
public class MemberListFilterRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 关键词(姓名/手机号)。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 等级标识。
/// </summary>
public string? TierId { get; set; }
}
/// <summary>
/// 会员列表分页请求。
/// </summary>
public sealed class MemberListRequest : MemberListFilterRequest
{
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 10;
}
/// <summary>
/// 会员详情请求。
/// </summary>
public sealed class MemberDetailRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 会员标识。
/// </summary>
public string MemberId { get; set; } = string.Empty;
}
/// <summary>
/// 保存会员标签请求。
/// </summary>
public sealed class SaveMemberTagsRequest
{
/// <summary>
/// 门店 ID可选未传表示当前商户全部可见门店
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 会员标识。
/// </summary>
public string MemberId { get; set; } = string.Empty;
/// <summary>
/// 标签集合。
/// </summary>
public List<string> Tags { get; set; } = [];
}
/// <summary>
/// 会员列表行响应。
/// </summary>
public sealed class MemberListItemResponse
{
/// <summary>
/// 会员标识。
/// </summary>
public string MemberId { get; set; } = string.Empty;
/// <summary>
/// 会员名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 头像文案。
/// </summary>
public string AvatarText { get; set; } = string.Empty;
/// <summary>
/// 头像颜色。
/// </summary>
public string AvatarColor { get; set; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string MobileMasked { get; set; } = string.Empty;
/// <summary>
/// 会员等级标识。
/// </summary>
public string? TierId { get; set; }
/// <summary>
/// 会员等级名称。
/// </summary>
public string TierName { get; set; } = string.Empty;
/// <summary>
/// 等级主题色。
/// </summary>
public string TierColorHex { get; set; } = string.Empty;
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 消费次数。
/// </summary>
public int OrderCount { get; set; }
/// <summary>
/// 最近消费时间yyyy-MM-dd
/// </summary>
public string LastOrderAt { get; set; } = string.Empty;
/// <summary>
/// 储值余额。
/// </summary>
public decimal StoredBalance { get; set; }
/// <summary>
/// 积分余额。
/// </summary>
public int PointsBalance { get; set; }
/// <summary>
/// 是否沉睡会员。
/// </summary>
public bool IsDormant { get; set; }
}
/// <summary>
/// 会员列表响应。
/// </summary>
public sealed class MemberListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<MemberListItemResponse> 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 MemberListStatsResponse
{
/// <summary>
/// 会员总数。
/// </summary>
public int TotalMembers { get; set; }
/// <summary>
/// 本月新增会员数。
/// </summary>
public int MonthlyNewMembers { get; set; }
/// <summary>
/// 活跃会员数。
/// </summary>
public int ActiveMembers { get; set; }
/// <summary>
/// 沉睡会员数。
/// </summary>
public int DormantMembers { get; set; }
}
/// <summary>
/// 会员最近订单响应。
/// </summary>
public sealed class MemberRecentOrderResponse
{
/// <summary>
/// 下单日期yyyy-MM-dd
/// </summary>
public string OrderedAt { get; set; } = string.Empty;
/// <summary>
/// 订单号。
/// </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 订单金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 订单状态文案。
/// </summary>
public string StatusText { get; set; } = string.Empty;
}
/// <summary>
/// 会员详情响应。
/// </summary>
public sealed class MemberDetailResponse
{
/// <summary>
/// 会员标识。
/// </summary>
public string MemberId { get; set; } = string.Empty;
/// <summary>
/// 会员名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 头像文案。
/// </summary>
public string AvatarText { get; set; } = string.Empty;
/// <summary>
/// 头像颜色。
/// </summary>
public string AvatarColor { get; set; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string MobileMasked { get; set; } = string.Empty;
/// <summary>
/// 注册时间yyyy-MM-dd
/// </summary>
public string JoinedAt { get; set; } = string.Empty;
/// <summary>
/// 会员等级标识。
/// </summary>
public string? TierId { get; set; }
/// <summary>
/// 会员等级名称。
/// </summary>
public string TierName { get; set; } = string.Empty;
/// <summary>
/// 等级主题色。
/// </summary>
public string TierColorHex { get; set; } = string.Empty;
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 消费次数。
/// </summary>
public int OrderCount { get; set; }
/// <summary>
/// 平均客单价。
/// </summary>
public decimal AverageAmount { get; set; }
/// <summary>
/// 储值余额。
/// </summary>
public decimal StoredBalance { get; set; }
/// <summary>
/// 储值实充余额。
/// </summary>
public decimal StoredRechargeBalance { get; set; }
/// <summary>
/// 储值赠金余额。
/// </summary>
public decimal StoredGiftBalance { get; set; }
/// <summary>
/// 积分余额。
/// </summary>
public int PointsBalance { get; set; }
/// <summary>
/// 会员标签。
/// </summary>
public List<string> Tags { get; set; } = [];
/// <summary>
/// 最近订单。
/// </summary>
public List<MemberRecentOrderResponse> RecentOrders { get; set; } = [];
}
/// <summary>
/// 会员导出响应。
/// </summary>
public sealed class MemberExportResponse
{
/// <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; }
}

View File

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

View File

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

View File

@@ -0,0 +1,399 @@
namespace TakeoutSaaS.TenantApi.Contracts.Member;
/// <summary>
/// 储值卡方案列表请求。
/// </summary>
public sealed class StoredCardPlanListRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
}
/// <summary>
/// 保存储值卡方案请求。
/// </summary>
public sealed class SaveStoredCardPlanRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 方案 ID编辑时传
/// </summary>
public string? PlanId { get; set; }
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; set; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; set; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; set; } = 100;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "enabled";
}
/// <summary>
/// 修改方案状态请求。
/// </summary>
public sealed class ChangeStoredCardPlanStatusRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 方案 ID。
/// </summary>
public string PlanId { get; set; } = string.Empty;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; set; } = "disabled";
}
/// <summary>
/// 删除方案请求。
/// </summary>
public sealed class DeleteStoredCardPlanRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 方案 ID。
/// </summary>
public string PlanId { get; set; } = string.Empty;
}
/// <summary>
/// 充值记录分页查询请求。
/// </summary>
public sealed class StoredCardRechargeRecordListRequest
{
/// <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>
/// 关键字(会员名称/手机号/单号)。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 8;
}
/// <summary>
/// 充值记录导出请求。
/// </summary>
public sealed class ExportStoredCardRechargeRecordRequest
{
/// <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>
/// 关键字(会员名称/手机号/单号)。
/// </summary>
public string? Keyword { get; set; }
}
/// <summary>
/// 写入充值记录请求。
/// </summary>
public sealed class WriteStoredCardRechargeRecordRequest
{
/// <summary>
/// 门店 ID。
/// </summary>
public string StoreId { get; set; } = string.Empty;
/// <summary>
/// 会员 ID必填
/// </summary>
public string MemberId { get; set; } = string.Empty;
/// <summary>
/// 方案 ID可空
/// </summary>
public string? PlanId { get; set; }
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; set; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; set; }
/// <summary>
/// 支付方式wechat/alipay/cash/card/balance
/// </summary>
public string PaymentMethod { get; set; } = "wechat";
/// <summary>
/// 充值时间(可空,默认当前时间)。
/// </summary>
public DateTime? RechargedAt { get; set; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; set; }
}
/// <summary>
/// 储值卡方案统计响应。
/// </summary>
public sealed class StoredCardPlanStatsResponse
{
/// <summary>
/// 储值总额。
/// </summary>
public decimal TotalRechargeAmount { get; set; }
/// <summary>
/// 赠金总额。
/// </summary>
public decimal TotalGiftAmount { get; set; }
/// <summary>
/// 本月充值。
/// </summary>
public decimal CurrentMonthRechargeAmount { get; set; }
/// <summary>
/// 储值用户。
/// </summary>
public int RechargeMemberCount { get; set; }
}
/// <summary>
/// 储值卡方案响应。
/// </summary>
public sealed class StoredCardPlanResponse
{
/// <summary>
/// 方案 ID。
/// </summary>
public string PlanId { get; set; } = string.Empty;
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; set; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; set; }
/// <summary>
/// 到账金额。
/// </summary>
public decimal ArrivedAmount { get; set; }
/// <summary>
/// 排序。
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 状态。
/// </summary>
public string Status { get; set; } = "enabled";
/// <summary>
/// 累计充值次数。
/// </summary>
public int RechargeCount { get; set; }
/// <summary>
/// 累计充值金额。
/// </summary>
public decimal TotalRechargeAmount { get; set; }
}
/// <summary>
/// 储值卡方案列表响应。
/// </summary>
public sealed class StoredCardPlanListResultResponse
{
/// <summary>
/// 方案列表。
/// </summary>
public List<StoredCardPlanResponse> Items { get; set; } = [];
/// <summary>
/// 页面统计。
/// </summary>
public StoredCardPlanStatsResponse Stats { get; set; } = new();
}
/// <summary>
/// 充值记录响应。
/// </summary>
public sealed class StoredCardRechargeRecordResponse
{
/// <summary>
/// 记录 ID。
/// </summary>
public string RecordId { get; set; } = string.Empty;
/// <summary>
/// 充值单号。
/// </summary>
public string RecordNo { get; set; } = string.Empty;
/// <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 decimal RechargeAmount { get; set; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; set; }
/// <summary>
/// 到账金额。
/// </summary>
public decimal ArrivedAmount { get; set; }
/// <summary>
/// 支付方式编码。
/// </summary>
public string PaymentMethod { get; set; } = "unknown";
/// <summary>
/// 支付方式文案。
/// </summary>
public string PaymentMethodText { get; set; } = "未知";
/// <summary>
/// 充值时间(本地显示字符串)。
/// </summary>
public string RechargedAt { get; set; } = string.Empty;
/// <summary>
/// 方案 ID。
/// </summary>
public string? PlanId { get; set; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; set; }
}
/// <summary>
/// 充值记录分页结果响应。
/// </summary>
public sealed class StoredCardRechargeRecordListResultResponse
{
/// <summary>
/// 列表项。
/// </summary>
public List<StoredCardRechargeRecordResponse> 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 StoredCardRechargeRecordExportResponse
{
/// <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; }
}

View File

@@ -0,0 +1,427 @@
namespace TakeoutSaaS.TenantApi.Contracts.Member;
/// <summary>
/// 会员等级列表项响应。
/// </summary>
public sealed class MemberTierListItemResponse
{
/// <summary>
/// 等级标识。
/// </summary>
public string TierId { get; set; } = string.Empty;
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 等级名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 图标键。
/// </summary>
public string IconKey { get; set; } = string.Empty;
/// <summary>
/// 主题色。
/// </summary>
public string ColorHex { get; set; } = string.Empty;
/// <summary>
/// 升级条件文案。
/// </summary>
public string ConditionText { get; set; } = string.Empty;
/// <summary>
/// 权益摘要。
/// </summary>
public List<string> Perks { get; set; } = [];
/// <summary>
/// 等级会员数。
/// </summary>
public int MemberCount { get; set; }
/// <summary>
/// 是否默认等级。
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// 是否可删除。
/// </summary>
public bool CanDelete { get; set; }
}
/// <summary>
/// 等级详情查询请求。
/// </summary>
public sealed class MemberTierDetailRequest
{
/// <summary>
/// 等级标识。
/// </summary>
public string? TierId { get; set; }
}
/// <summary>
/// 等级规则响应。
/// </summary>
public sealed class MemberTierRuleResponse
{
/// <summary>
/// 升级规则类型。
/// </summary>
public string UpgradeRuleType { get; set; } = "none";
/// <summary>
/// 升级累计消费门槛。
/// </summary>
public decimal? UpgradeAmountThreshold { get; set; }
/// <summary>
/// 升级消费次数门槛。
/// </summary>
public int? UpgradeOrderCountThreshold { get; set; }
/// <summary>
/// 降级观察窗口天数。
/// </summary>
public int DowngradeWindowDays { get; set; }
}
/// <summary>
/// 折扣权益响应。
/// </summary>
public sealed class MemberTierDiscountBenefitResponse
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 折扣值。
/// </summary>
public decimal? DiscountRate { get; set; }
}
/// <summary>
/// 积分倍率权益响应。
/// </summary>
public sealed class MemberTierPointMultiplierBenefitResponse
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 倍率。
/// </summary>
public decimal? Multiplier { get; set; }
}
/// <summary>
/// 生日特权响应。
/// </summary>
public sealed class MemberTierBirthdayBenefitResponse
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 是否双倍积分。
/// </summary>
public bool DoublePointsEnabled { get; set; }
/// <summary>
/// 券模板 ID。
/// </summary>
public List<string> CouponTemplateIds { get; set; } = [];
}
/// <summary>
/// 每月赠券响应。
/// </summary>
public sealed class MemberTierMonthlyCouponBenefitResponse
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 每月发放日。
/// </summary>
public int GrantDay { get; set; }
/// <summary>
/// 券模板 ID。
/// </summary>
public List<string> CouponTemplateIds { get; set; } = [];
}
/// <summary>
/// 免配送费权益响应。
/// </summary>
public sealed class MemberTierFreeDeliveryBenefitResponse
{
/// <summary>
/// 是否启用。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 每月免配送费次数。
/// </summary>
public int MonthlyFreeTimes { get; set; }
}
/// <summary>
/// 等级权益响应。
/// </summary>
public sealed class MemberTierBenefitsResponse
{
/// <summary>
/// 折扣权益。
/// </summary>
public MemberTierDiscountBenefitResponse Discount { get; set; } = new();
/// <summary>
/// 积分倍率权益。
/// </summary>
public MemberTierPointMultiplierBenefitResponse PointMultiplier { get; set; } = new();
/// <summary>
/// 生日特权。
/// </summary>
public MemberTierBirthdayBenefitResponse Birthday { get; set; } = new();
/// <summary>
/// 每月赠券。
/// </summary>
public MemberTierMonthlyCouponBenefitResponse MonthlyCoupon { get; set; } = new();
/// <summary>
/// 免配送费权益。
/// </summary>
public MemberTierFreeDeliveryBenefitResponse FreeDelivery { get; set; } = new();
/// <summary>
/// 优先配送。
/// </summary>
public bool PriorityDeliveryEnabled { get; set; }
/// <summary>
/// 专属客服。
/// </summary>
public bool ExclusiveServiceEnabled { get; set; }
}
/// <summary>
/// 等级详情响应。
/// </summary>
public sealed class MemberTierDetailResponse
{
/// <summary>
/// 等级标识。
/// </summary>
public string? TierId { get; set; }
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 等级名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 图标键。
/// </summary>
public string IconKey { get; set; } = string.Empty;
/// <summary>
/// 主题色。
/// </summary>
public string ColorHex { get; set; } = string.Empty;
/// <summary>
/// 是否默认等级。
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// 升降级规则。
/// </summary>
public MemberTierRuleResponse Rule { get; set; } = new();
/// <summary>
/// 等级权益。
/// </summary>
public MemberTierBenefitsResponse Benefits { get; set; } = new();
/// <summary>
/// 是否可删除。
/// </summary>
public bool CanDelete { get; set; }
}
/// <summary>
/// 保存等级请求。
/// </summary>
public sealed class SaveMemberTierRequest
{
/// <summary>
/// 等级标识(为空时新增)。
/// </summary>
public string? TierId { get; set; }
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 等级名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 图标键。
/// </summary>
public string IconKey { get; set; } = "user";
/// <summary>
/// 主题色。
/// </summary>
public string ColorHex { get; set; } = "#999999";
/// <summary>
/// 是否默认等级。
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// 升降级规则。
/// </summary>
public MemberTierRuleResponse Rule { get; set; } = new();
/// <summary>
/// 等级权益。
/// </summary>
public MemberTierBenefitsResponse Benefits { get; set; } = new();
}
/// <summary>
/// 删除等级请求。
/// </summary>
public sealed class DeleteMemberTierRequest
{
/// <summary>
/// 等级标识。
/// </summary>
public string TierId { get; set; } = string.Empty;
}
/// <summary>
/// 会员日配置响应。
/// </summary>
public sealed class MemberDaySettingResponse
{
/// <summary>
/// 是否启用会员日。
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// 周几1-7对应周一到周日
/// </summary>
public int Weekday { get; set; }
/// <summary>
/// 会员日额外折扣。
/// </summary>
public decimal ExtraDiscountRate { get; set; }
}
/// <summary>
/// 保存会员日配置请求。
/// </summary>
public sealed class SaveMemberDaySettingRequest
{
/// <summary>
/// 是否启用会员日。
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// 周几1-7对应周一到周日
/// </summary>
public int Weekday { get; set; }
/// <summary>
/// 会员日额外折扣。
/// </summary>
public decimal ExtraDiscountRate { get; set; }
}
/// <summary>
/// 优惠券选择器请求。
/// </summary>
public sealed class MemberCouponPickerRequest
{
/// <summary>
/// 门店 ID可选
/// </summary>
public string? StoreId { get; set; }
/// <summary>
/// 关键词。
/// </summary>
public string? Keyword { get; set; }
}
/// <summary>
/// 优惠券选择器项响应。
/// </summary>
public sealed class MemberCouponPickerItemResponse
{
/// <summary>
/// 券模板标识。
/// </summary>
public string CouponTemplateId { get; set; } = string.Empty;
/// <summary>
/// 券名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 券类型。
/// </summary>
public string CouponType { get; set; } = string.Empty;
/// <summary>
/// 面值或折扣值。
/// </summary>
public decimal Value { get; set; }
/// <summary>
/// 最低消费门槛。
/// </summary>
public decimal? MinimumSpend { get; set; }
/// <summary>
/// 展示文案。
/// </summary>
public string DisplayText { get; set; } = string.Empty;
}

View File

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

View File

@@ -0,0 +1,535 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
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.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
using TakeoutSaaS.TenantApi.Contracts.Customer;
namespace TakeoutSaaS.TenantApi.Controllers;
/// <summary>
/// 客户分析。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/tenant/v{version:apiVersion}/customer/analysis")]
public sealed class CustomerAnalysisController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService)
: BaseApiController
{
private const string ViewPermission = "tenant:customer:analysis:view";
/// <summary>
/// 获取客户分析总览。
/// </summary>
[HttpGet("overview")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<CustomerAnalysisOverviewResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CustomerAnalysisOverviewResponse>> Overview(
[FromQuery] CustomerAnalysisOverviewRequest request,
CancellationToken cancellationToken)
{
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var (periodCode, periodDays) = ParsePeriod(request.Period);
var result = await mediator.Send(new GetCustomerAnalysisOverviewQuery
{
VisibleStoreIds = visibleStoreIds,
PeriodCode = periodCode,
PeriodDays = periodDays
}, cancellationToken);
return ApiResponse<CustomerAnalysisOverviewResponse>.Ok(MapOverview(result));
}
/// <summary>
/// 获取客群明细。
/// </summary>
[HttpGet("segment/list")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<CustomerAnalysisSegmentListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CustomerAnalysisSegmentListResultResponse>> SegmentList(
[FromQuery] CustomerAnalysisSegmentListRequest request,
CancellationToken cancellationToken)
{
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var (periodCode, periodDays) = ParsePeriod(request.Period);
var result = await mediator.Send(new GetCustomerAnalysisSegmentListQuery
{
VisibleStoreIds = visibleStoreIds,
PeriodCode = periodCode,
PeriodDays = periodDays,
SegmentCode = request.SegmentCode,
Keyword = request.Keyword,
Page = Math.Max(1, request.Page),
PageSize = Math.Clamp(request.PageSize, 1, 200)
}, cancellationToken);
return ApiResponse<CustomerAnalysisSegmentListResultResponse>.Ok(MapSegmentList(result));
}
/// <summary>
/// 获取客户详情(分析页二级抽屉)。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<CustomerDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CustomerDetailResponse>> Detail(
[FromQuery] CustomerDetailRequest request,
CancellationToken cancellationToken)
{
var customerKey = NormalizePhone(request.CustomerKey);
if (string.IsNullOrWhiteSpace(customerKey))
{
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
}
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new GetCustomerDetailQuery
{
VisibleStoreIds = visibleStoreIds,
CustomerKey = customerKey
}, cancellationToken);
if (result is null)
{
return ApiResponse<CustomerDetailResponse>.Error(ErrorCodes.NotFound, "客户不存在");
}
return ApiResponse<CustomerDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 获取客户完整画像(分析页二级抽屉)。
/// </summary>
[HttpGet("profile")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<CustomerProfileResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CustomerProfileResponse>> Profile(
[FromQuery] CustomerProfileRequest request,
CancellationToken cancellationToken)
{
var customerKey = NormalizePhone(request.CustomerKey);
if (string.IsNullOrWhiteSpace(customerKey))
{
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
}
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new GetCustomerProfileQuery
{
VisibleStoreIds = visibleStoreIds,
CustomerKey = customerKey
}, cancellationToken);
if (result is null)
{
return ApiResponse<CustomerProfileResponse>.Error(ErrorCodes.NotFound, "客户不存在");
}
return ApiResponse<CustomerProfileResponse>.Ok(MapProfile(result));
}
/// <summary>
/// 获取会员详情。
/// </summary>
[HttpGet("member/detail")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<CustomerMemberDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CustomerMemberDetailResponse>> MemberDetail(
[FromQuery] CustomerMemberDetailRequest request,
CancellationToken cancellationToken)
{
var customerKey = NormalizePhone(request.CustomerKey);
if (string.IsNullOrWhiteSpace(customerKey))
{
return ApiResponse<CustomerMemberDetailResponse>.Error(ErrorCodes.BadRequest, "customerKey 非法");
}
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new GetCustomerMemberDetailQuery
{
VisibleStoreIds = visibleStoreIds,
CustomerKey = customerKey
}, cancellationToken);
if (result is null)
{
return ApiResponse<CustomerMemberDetailResponse>.Error(ErrorCodes.NotFound, "客户不存在");
}
return ApiResponse<CustomerMemberDetailResponse>.Ok(MapMemberDetail(result));
}
/// <summary>
/// 导出客户分析报表。
/// </summary>
[HttpGet("export")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<CustomerExportResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<CustomerExportResponse>> Export(
[FromQuery] CustomerAnalysisExportRequest request,
CancellationToken cancellationToken)
{
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var (periodCode, periodDays) = ParsePeriod(request.Period);
var result = await mediator.Send(new ExportCustomerAnalysisCsvQuery
{
VisibleStoreIds = visibleStoreIds,
PeriodCode = periodCode,
PeriodDays = periodDays
}, cancellationToken);
return ApiResponse<CustomerExportResponse>.Ok(new CustomerExportResponse
{
FileName = result.FileName,
FileContentBase64 = result.FileContentBase64,
TotalCount = result.TotalCount
});
}
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
string? storeId,
CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
if (!string.IsNullOrWhiteSpace(storeId))
{
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
await StoreApiHelpers.EnsureStoreAccessibleAsync(
dbContext,
tenantId,
merchantId,
parsedStoreId,
cancellationToken);
return [parsedStoreId];
}
var allStoreIds = await dbContext.Stores
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
.Select(item => item.Id)
.OrderBy(item => item)
.ToListAsync(cancellationToken);
if (allStoreIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
}
return allStoreIds;
}
private static (string PeriodCode, int PeriodDays) ParsePeriod(string? period)
{
var normalized = (period ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalized))
{
return ("30d", 30);
}
return normalized switch
{
"7" or "7d" => ("7d", 7),
"30" or "30d" => ("30d", 30),
"90" or "90d" => ("90d", 90),
"365" or "365d" or "1y" or "1year" => ("365d", 365),
_ => throw new BusinessException(ErrorCodes.BadRequest, "period 参数不合法")
};
}
private static string NormalizePhone(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var chars = value.Where(char.IsDigit).ToArray();
return chars.Length == 0 ? string.Empty : new string(chars);
}
private static CustomerAnalysisOverviewResponse MapOverview(CustomerAnalysisOverviewDto source)
{
return new CustomerAnalysisOverviewResponse
{
PeriodCode = source.PeriodCode,
PeriodDays = source.PeriodDays,
TotalCustomers = source.TotalCustomers,
NewCustomers = source.NewCustomers,
GrowthRatePercent = source.GrowthRatePercent,
NewCustomersDailyAverage = source.NewCustomersDailyAverage,
ActiveCustomers = source.ActiveCustomers,
ActiveRatePercent = source.ActiveRatePercent,
AverageLifetimeValue = source.AverageLifetimeValue,
GrowthTrend = source.GrowthTrend.Select(MapTrendPoint).ToList(),
Composition = source.Composition.Select(MapCompositionItem).ToList(),
AmountDistribution = source.AmountDistribution.Select(MapAmountDistributionItem).ToList(),
RfmRows = source.RfmRows.Select(MapRfmRow).ToList(),
TopCustomers = source.TopCustomers.Select(MapTopCustomer).ToList()
};
}
private static CustomerAnalysisTrendPointResponse MapTrendPoint(CustomerAnalysisTrendPointDto source)
{
return new CustomerAnalysisTrendPointResponse
{
Label = source.Label,
Value = source.Value
};
}
private static CustomerAnalysisCompositionItemResponse MapCompositionItem(CustomerAnalysisCompositionItemDto source)
{
return new CustomerAnalysisCompositionItemResponse
{
SegmentCode = source.SegmentCode,
Label = source.Label,
Count = source.Count,
Percent = source.Percent,
Tone = source.Tone
};
}
private static CustomerAnalysisAmountDistributionItemResponse MapAmountDistributionItem(
CustomerAnalysisAmountDistributionItemDto source)
{
return new CustomerAnalysisAmountDistributionItemResponse
{
SegmentCode = source.SegmentCode,
Label = source.Label,
Count = source.Count,
Percent = source.Percent
};
}
private static CustomerAnalysisRfmRowResponse MapRfmRow(CustomerAnalysisRfmRowDto source)
{
return new CustomerAnalysisRfmRowResponse
{
Label = source.Label,
Cells = source.Cells.Select(MapRfmCell).ToList()
};
}
private static CustomerAnalysisRfmCellResponse MapRfmCell(CustomerAnalysisRfmCellDto source)
{
return new CustomerAnalysisRfmCellResponse
{
SegmentCode = source.SegmentCode,
Label = source.Label,
Count = source.Count,
Tone = source.Tone
};
}
private static CustomerAnalysisTopCustomerResponse MapTopCustomer(CustomerAnalysisTopCustomerDto source)
{
return new CustomerAnalysisTopCustomerResponse
{
Rank = source.Rank,
CustomerKey = source.CustomerKey,
Name = source.Name,
PhoneMasked = source.PhoneMasked,
TotalAmount = source.TotalAmount,
OrderCount = source.OrderCount,
AverageAmount = source.AverageAmount,
LastOrderAt = ToDateOnly(source.LastOrderAt),
Tags = source.Tags.Select(MapTag).ToList()
};
}
private static CustomerDetailResponse MapDetail(CustomerDetailDto source)
{
return new CustomerDetailResponse
{
CustomerKey = source.CustomerKey,
Name = source.Name,
PhoneMasked = source.PhoneMasked,
RegisteredAt = ToDateOnly(source.RegisteredAt),
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
Source = source.Source,
Tags = source.Tags.Select(MapTag).ToList(),
Member = MapMember(source.Member),
TotalOrders = source.TotalOrders,
TotalAmount = source.TotalAmount,
AverageAmount = source.AverageAmount,
RepurchaseRatePercent = source.RepurchaseRatePercent,
Preference = MapPreference(source.Preference),
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
Trend = source.Trend.Select(MapTrend).ToList(),
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
};
}
private static CustomerProfileResponse MapProfile(CustomerProfileDto source)
{
return new CustomerProfileResponse
{
CustomerKey = source.CustomerKey,
Name = source.Name,
PhoneMasked = source.PhoneMasked,
RegisteredAt = ToDateOnly(source.RegisteredAt),
FirstOrderAt = ToDateOnly(source.FirstOrderAt),
Source = source.Source,
Tags = source.Tags.Select(MapTag).ToList(),
Member = MapMember(source.Member),
TotalOrders = source.TotalOrders,
TotalAmount = source.TotalAmount,
AverageAmount = source.AverageAmount,
RepurchaseRatePercent = source.RepurchaseRatePercent,
AverageOrderIntervalDays = source.AverageOrderIntervalDays,
Preference = MapPreference(source.Preference),
TopProducts = source.TopProducts.Select(MapTopProduct).ToList(),
Trend = source.Trend.Select(MapTrend).ToList(),
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
};
}
private static CustomerAnalysisSegmentListResultResponse MapSegmentList(CustomerAnalysisSegmentListResultDto source)
{
return new CustomerAnalysisSegmentListResultResponse
{
SegmentCode = source.SegmentCode,
SegmentTitle = source.SegmentTitle,
SegmentDescription = source.SegmentDescription,
Items = source.Items.Select(MapSegmentListItem).ToList(),
Page = source.Page,
PageSize = source.PageSize,
TotalCount = source.TotalCount
};
}
private static CustomerAnalysisSegmentListItemResponse MapSegmentListItem(CustomerAnalysisSegmentListItemDto source)
{
return new CustomerAnalysisSegmentListItemResponse
{
CustomerKey = source.CustomerKey,
Name = source.Name,
PhoneMasked = source.PhoneMasked,
AvatarText = source.AvatarText,
AvatarColor = source.AvatarColor,
Tags = source.Tags.Select(MapTag).ToList(),
IsMember = source.IsMember,
MemberTierName = source.MemberTierName,
TotalAmount = source.TotalAmount,
OrderCount = source.OrderCount,
AverageAmount = source.AverageAmount,
RegisteredAt = ToDateOnly(source.RegisteredAt),
LastOrderAt = ToDateOnly(source.LastOrderAt),
IsDimmed = source.IsDimmed
};
}
private static CustomerMemberDetailResponse MapMemberDetail(CustomerMemberDetailDto source)
{
return new CustomerMemberDetailResponse
{
CustomerKey = source.CustomerKey,
Name = source.Name,
PhoneMasked = source.PhoneMasked,
Source = source.Source,
RegisteredAt = ToDateOnly(source.RegisteredAt),
LastOrderAt = ToDateOnly(source.LastOrderAt),
Member = MapMember(source.Member),
Tags = source.Tags.Select(MapTag).ToList(),
TotalOrders = source.TotalOrders,
TotalAmount = source.TotalAmount,
AverageAmount = source.AverageAmount,
RepurchaseRatePercent = source.RepurchaseRatePercent,
RecentOrders = source.RecentOrders.Select(MapRecentOrder).ToList()
};
}
private static CustomerTagResponse MapTag(CustomerTagDto source)
{
return new CustomerTagResponse
{
Code = source.Code,
Label = source.Label,
Tone = source.Tone
};
}
private static CustomerMemberSummaryResponse MapMember(CustomerMemberSummaryDto source)
{
return new CustomerMemberSummaryResponse
{
IsMember = source.IsMember,
TierName = source.TierName,
PointsBalance = source.PointsBalance,
GrowthValue = source.GrowthValue,
JoinedAt = source.JoinedAt.HasValue ? ToDateOnly(source.JoinedAt.Value) : string.Empty
};
}
private static CustomerPreferenceResponse MapPreference(CustomerPreferenceDto source)
{
return new CustomerPreferenceResponse
{
PreferredCategories = source.PreferredCategories.ToList(),
PreferredOrderPeaks = source.PreferredOrderPeaks,
PreferredDelivery = source.PreferredDelivery,
PreferredPaymentMethod = source.PreferredPaymentMethod,
AverageDeliveryDistance = source.AverageDeliveryDistance
};
}
private static CustomerTopProductResponse MapTopProduct(CustomerTopProductDto source)
{
return new CustomerTopProductResponse
{
Rank = source.Rank,
ProductName = source.ProductName,
Count = source.Count,
ProportionPercent = source.ProportionPercent
};
}
private static CustomerTrendPointResponse MapTrend(CustomerTrendPointDto source)
{
return new CustomerTrendPointResponse
{
Label = source.Label,
Amount = source.Amount
};
}
private static CustomerRecentOrderResponse MapRecentOrder(CustomerRecentOrderDto source)
{
return new CustomerRecentOrderResponse
{
OrderNo = source.OrderNo,
Amount = source.Amount,
ItemsSummary = source.ItemsSummary,
DeliveryType = source.DeliveryType,
Status = source.Status,
OrderedAt = ToDateTime(source.OrderedAt)
};
}
private static string ToDateOnly(DateTime value)
{
return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
private static string ToDateTime(DateTime value)
{
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
}
}

View File

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

View File

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

View File

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

View File

@@ -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>
/// 批量导出报表 ZIPPDF + 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
};
}
}

View File

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

View File

@@ -0,0 +1,343 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Finance.Transactions.Dto;
using TakeoutSaaS.Application.App.Finance.Transactions.Queries;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Orders.Enums;
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/transaction")]
public sealed class FinanceTransactionController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService) : BaseApiController
{
private const string ViewPermission = "tenant:finance:transaction:view";
private const string DetailPermission = "tenant:finance:transaction:detail";
private const string ExportPermission = "tenant:finance:transaction:export";
/// <summary>
/// 查询交易流水列表。
/// </summary>
[HttpGet("list")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceTransactionListResultResponse>> List(
[FromQuery] FinanceTransactionListRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验筛选参数。
var parsed = await ParseFilterAsync(request, cancellationToken);
// 2. 发起查询并映射响应。
var result = await mediator.Send(new SearchFinanceTransactionListQuery
{
StoreId = parsed.StoreId,
StartAt = parsed.StartAt,
EndAt = parsed.EndAt,
TransactionType = parsed.TransactionType,
DeliveryType = parsed.DeliveryType,
PaymentMethod = parsed.PaymentMethod,
Keyword = request.Keyword,
Page = Math.Max(1, request.Page),
PageSize = Math.Clamp(request.PageSize, 1, 200)
}, cancellationToken);
return ApiResponse<FinanceTransactionListResultResponse>.Ok(new FinanceTransactionListResultResponse
{
Items = result.Items.Select(MapListItem).ToList(),
Total = result.Total,
Page = result.Page,
PageSize = result.PageSize,
PageIncomeAmount = result.PageIncomeAmount,
PageRefundAmount = result.PageRefundAmount
});
}
/// <summary>
/// 查询交易流水统计。
/// </summary>
[HttpGet("stats")]
[PermissionAuthorize(ViewPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionStatsResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceTransactionStatsResponse>> Stats(
[FromQuery] FinanceTransactionFilterRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验筛选参数。
var parsed = await ParseFilterAsync(request, cancellationToken);
// 2. 发起查询并映射响应。
var result = await mediator.Send(new GetFinanceTransactionStatsQuery
{
StoreId = parsed.StoreId,
StartAt = parsed.StartAt,
EndAt = parsed.EndAt,
TransactionType = parsed.TransactionType,
DeliveryType = parsed.DeliveryType,
PaymentMethod = parsed.PaymentMethod,
Keyword = request.Keyword
}, cancellationToken);
return ApiResponse<FinanceTransactionStatsResponse>.Ok(new FinanceTransactionStatsResponse
{
TotalIncome = result.TotalIncome,
TotalRefund = result.TotalRefund,
TotalCount = result.TotalCount
});
}
/// <summary>
/// 查询交易流水详情。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission, DetailPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceTransactionDetailResponse>> Detail(
[FromQuery] FinanceTransactionDetailRequest request,
CancellationToken cancellationToken)
{
// 1. 校验门店参数与门店访问权限。
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
// 2. 解析交易复合标识。
if (!TryParseTransactionId(request.TransactionId, out var sourceType, out var sourceId))
{
return ApiResponse<FinanceTransactionDetailResponse>.Error(ErrorCodes.BadRequest, "transactionId 非法");
}
// 3. 查询详情并返回。
var detail = await mediator.Send(new GetFinanceTransactionDetailQuery
{
StoreId = storeId,
SourceType = sourceType,
SourceId = sourceId
}, cancellationToken);
if (detail is null)
{
return ApiResponse<FinanceTransactionDetailResponse>.Error(ErrorCodes.NotFound, "交易流水不存在");
}
return ApiResponse<FinanceTransactionDetailResponse>.Ok(MapDetail(detail));
}
/// <summary>
/// 导出交易流水 CSV。
/// </summary>
[HttpGet("export")]
[PermissionAuthorize(ExportPermission)]
[ProducesResponseType(typeof(ApiResponse<FinanceTransactionExportResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<FinanceTransactionExportResponse>> Export(
[FromQuery] FinanceTransactionFilterRequest request,
CancellationToken cancellationToken)
{
// 1. 解析并校验筛选参数。
var parsed = await ParseFilterAsync(request, cancellationToken);
// 2. 发起导出并返回结果。
var result = await mediator.Send(new ExportFinanceTransactionCsvQuery
{
StoreId = parsed.StoreId,
StartAt = parsed.StartAt,
EndAt = parsed.EndAt,
TransactionType = parsed.TransactionType,
DeliveryType = parsed.DeliveryType,
PaymentMethod = parsed.PaymentMethod,
Keyword = request.Keyword
}, cancellationToken);
return ApiResponse<FinanceTransactionExportResponse>.Ok(new FinanceTransactionExportResponse
{
FileName = result.FileName,
FileContentBase64 = result.FileContentBase64,
TotalCount = result.TotalCount
});
}
private async Task<(long StoreId, DateTime? StartAt, DateTime? EndAt, FinanceTransactionType? TransactionType, DeliveryType? DeliveryType, PaymentMethod? PaymentMethod)> ParseFilterAsync(
FinanceTransactionFilterRequest 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, "开始日期不能晚于结束日期");
}
var transactionType = ParseTransactionType(request.Type);
var deliveryType = ParseDeliveryType(request.Channel);
var paymentMethod = ParsePaymentMethod(request.PaymentMethod);
return (storeId, startAt, endAt, transactionType, deliveryType, paymentMethod);
}
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)
{
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 FinanceTransactionType? ParseTransactionType(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"income" => FinanceTransactionType.Income,
"refund" => FinanceTransactionType.Refund,
"stored_card_recharge" => FinanceTransactionType.StoredCardRecharge,
"point_redeem" => FinanceTransactionType.PointRedeem,
_ => null
};
}
private static DeliveryType? ParseDeliveryType(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"delivery" => DeliveryType.Delivery,
"pickup" => DeliveryType.Pickup,
"dine_in" => DeliveryType.DineIn,
_ => null
};
}
private static PaymentMethod? ParsePaymentMethod(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"wechat" => PaymentMethod.WeChatPay,
"alipay" => PaymentMethod.Alipay,
"cash" => PaymentMethod.Cash,
"card" => PaymentMethod.Card,
"balance" => PaymentMethod.Balance,
_ => null
};
}
private static bool TryParseTransactionId(string? value, out FinanceTransactionSourceType sourceType, out long sourceId)
{
sourceType = default;
sourceId = 0;
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
var parts = normalized.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length != 2)
{
return false;
}
if (!long.TryParse(parts[1], out sourceId) || sourceId <= 0)
{
return false;
}
sourceType = parts[0].ToLowerInvariant() switch
{
"payment" => FinanceTransactionSourceType.PaymentRecord,
"payment_refund" => FinanceTransactionSourceType.PaymentRefundRecord,
"refund_request" => FinanceTransactionSourceType.RefundRequest,
"stored_card_recharge" => FinanceTransactionSourceType.StoredCardRechargeRecord,
"member_point" => FinanceTransactionSourceType.MemberPointLedger,
_ => default
};
return sourceType != default;
}
private static FinanceTransactionListItemResponse MapListItem(FinanceTransactionListItemDto source)
{
return new FinanceTransactionListItemResponse
{
TransactionId = source.TransactionId,
TransactionNo = source.TransactionNo,
OrderNo = source.OrderNo,
Type = source.TransactionType,
TypeText = source.TransactionTypeText,
Channel = source.ChannelText,
PaymentMethod = source.PaymentMethodText,
Amount = source.AmountSigned,
OccurredAt = source.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
Remark = source.Remark,
IsIncome = source.IsIncome
};
}
private static FinanceTransactionDetailResponse MapDetail(FinanceTransactionDetailDto source)
{
return new FinanceTransactionDetailResponse
{
TransactionId = source.TransactionId,
TransactionNo = source.TransactionNo,
Type = source.TransactionType,
TypeText = source.TransactionTypeText,
StoreId = source.StoreId.ToString(),
OrderNo = source.OrderNo,
Channel = source.ChannelText,
PaymentMethod = source.PaymentMethodText,
Amount = source.AmountSigned,
OccurredAt = source.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
Remark = source.Remark,
CustomerName = source.CustomerName,
CustomerPhone = source.CustomerPhone,
RefundNo = source.RefundNo,
RefundReason = source.RefundReason,
MemberName = source.MemberName,
MemberMobileMasked = source.MemberMobileMasked,
RechargeAmount = source.RechargeAmount,
GiftAmount = source.GiftAmount,
ArrivedAmount = source.ArrivedAmount,
PointChangeAmount = source.PointChangeAmount,
PointBalanceAfterChange = source.PointBalanceAfterChange
};
}
}

View File

@@ -0,0 +1,251 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Application.App.Members.Commands;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Application.App.Members.Queries;
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.Exceptions;
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/list")]
public sealed class MemberController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService)
: BaseApiController
{
private const string ViewPermission = "tenant:member:view";
private const string ManagePermission = "tenant:member:manage";
/// <summary>
/// 获取会员列表。
/// </summary>
[HttpGet("list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberListResultResponse>> List(
[FromQuery] MemberListRequest request,
CancellationToken cancellationToken)
{
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new SearchMemberListQuery
{
VisibleStoreIds = visibleStoreIds,
Keyword = request.Keyword,
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId),
Page = Math.Max(1, request.Page),
PageSize = Math.Clamp(request.PageSize, 1, 200)
}, cancellationToken);
return ApiResponse<MemberListResultResponse>.Ok(new MemberListResultResponse
{
Items = result.Items.Select(MapListItem).ToList(),
Total = result.TotalCount,
Page = result.Page,
PageSize = result.PageSize
});
}
/// <summary>
/// 获取会员列表统计。
/// </summary>
[HttpGet("stats")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberListStatsResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberListStatsResponse>> Stats(
[FromQuery] MemberListFilterRequest request,
CancellationToken cancellationToken)
{
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new GetMemberListStatsQuery
{
VisibleStoreIds = visibleStoreIds,
Keyword = request.Keyword,
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId)
}, cancellationToken);
return ApiResponse<MemberListStatsResponse>.Ok(new MemberListStatsResponse
{
TotalMembers = result.TotalMembers,
MonthlyNewMembers = result.MonthlyNewMembers,
ActiveMembers = result.ActiveMembers,
DormantMembers = result.DormantMembers
});
}
/// <summary>
/// 获取会员详情。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberDetailResponse>> Detail(
[FromQuery] MemberDetailRequest request,
CancellationToken cancellationToken)
{
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new GetMemberDetailQuery
{
VisibleStoreIds = visibleStoreIds,
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId))
}, cancellationToken);
if (result is null)
{
return ApiResponse<MemberDetailResponse>.Error(ErrorCodes.NotFound, "会员不存在");
}
return ApiResponse<MemberDetailResponse>.Ok(MapDetail(result));
}
/// <summary>
/// 保存会员标签。
/// </summary>
[HttpPost("tags")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> SaveTags(
[FromBody] SaveMemberTagsRequest request,
CancellationToken cancellationToken)
{
_ = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
await mediator.Send(new SaveMemberTagsCommand
{
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)),
Tags = (request.Tags ?? []).ToList()
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 导出会员列表 CSV。
/// </summary>
[HttpGet("export")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberExportResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberExportResponse>> Export(
[FromQuery] MemberListFilterRequest request,
CancellationToken cancellationToken)
{
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new ExportMemberCsvQuery
{
VisibleStoreIds = visibleStoreIds,
Keyword = request.Keyword,
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId)
}, cancellationToken);
return ApiResponse<MemberExportResponse>.Ok(new MemberExportResponse
{
FileName = result.FileName,
FileContentBase64 = result.FileContentBase64,
TotalCount = result.TotalCount
});
}
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
string? storeId,
CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
if (!string.IsNullOrWhiteSpace(storeId))
{
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
await StoreApiHelpers.EnsureStoreAccessibleAsync(
dbContext,
tenantId,
merchantId,
parsedStoreId,
cancellationToken);
return [parsedStoreId];
}
var allStoreIds = await dbContext.Stores
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
.Select(item => item.Id)
.OrderBy(item => item)
.ToListAsync(cancellationToken);
if (allStoreIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
}
return allStoreIds;
}
private static MemberListItemResponse MapListItem(MemberListItemDto source)
{
return new MemberListItemResponse
{
MemberId = source.MemberId.ToString(),
Name = source.Name,
AvatarText = source.AvatarText,
AvatarColor = source.AvatarColor,
MobileMasked = source.MobileMasked,
TierId = source.TierId?.ToString(),
TierName = source.TierName,
TierColorHex = source.TierColorHex,
TotalAmount = source.TotalAmount,
OrderCount = source.OrderCount,
LastOrderAt = source.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
StoredBalance = source.StoredBalance,
PointsBalance = source.PointsBalance,
IsDormant = source.IsDormant
};
}
private static MemberDetailResponse MapDetail(MemberDetailDto source)
{
return new MemberDetailResponse
{
MemberId = source.MemberId.ToString(),
Name = source.Name,
AvatarText = source.AvatarText,
AvatarColor = source.AvatarColor,
MobileMasked = source.MobileMasked,
JoinedAt = source.JoinedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
TierId = source.TierId?.ToString(),
TierName = source.TierName,
TierColorHex = source.TierColorHex,
TotalAmount = source.TotalAmount,
OrderCount = source.OrderCount,
AverageAmount = source.AverageAmount,
StoredBalance = source.StoredBalance,
StoredRechargeBalance = source.StoredRechargeBalance,
StoredGiftBalance = source.StoredGiftBalance,
PointsBalance = source.PointsBalance,
Tags = source.Tags.ToList(),
RecentOrders = source.RecentOrders.Select(item => new MemberRecentOrderResponse
{
OrderedAt = item.OrderedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
OrderNo = item.OrderNo,
Amount = item.Amount,
StatusText = item.StatusText
}).ToList()
};
}
}

View File

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

View File

@@ -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" => "手动核销",
_ => "未知"
};
}
}

View File

@@ -0,0 +1,283 @@
using System.Globalization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Application.App.Members.StoredCard.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/stored-card")]
public sealed class MemberStoredCardController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService)
: BaseApiController
{
private const string ViewPermission = "tenant:member:stored-card:view";
private const string ManagePermission = "tenant:member:stored-card:manage";
/// <summary>
/// 获取储值卡方案列表。
/// </summary>
[HttpGet("plan/list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<StoredCardPlanListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoredCardPlanListResultResponse>> PlanList(
[FromQuery] StoredCardPlanListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetStoredCardPlanListQuery
{
StoreId = storeId
}, cancellationToken);
return ApiResponse<StoredCardPlanListResultResponse>.Ok(new StoredCardPlanListResultResponse
{
Items = result.Items.Select(MapPlan).ToList(),
Stats = new StoredCardPlanStatsResponse
{
TotalRechargeAmount = result.Stats.TotalRechargeAmount,
TotalGiftAmount = result.Stats.TotalGiftAmount,
CurrentMonthRechargeAmount = result.Stats.CurrentMonthRechargeAmount,
RechargeMemberCount = result.Stats.RechargeMemberCount
}
});
}
/// <summary>
/// 保存储值卡方案。
/// </summary>
[HttpPost("plan/save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<StoredCardPlanResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoredCardPlanResponse>> SavePlan(
[FromBody] SaveStoredCardPlanRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new SaveStoredCardPlanCommand
{
StoreId = storeId,
PlanId = StoreApiHelpers.ParseSnowflakeOrNull(request.PlanId),
RechargeAmount = request.RechargeAmount,
GiftAmount = request.GiftAmount,
SortOrder = request.SortOrder,
Status = request.Status
}, cancellationToken);
return ApiResponse<StoredCardPlanResponse>.Ok(MapPlan(result));
}
/// <summary>
/// 修改储值卡方案状态。
/// </summary>
[HttpPost("plan/status")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<StoredCardPlanResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoredCardPlanResponse>> ChangePlanStatus(
[FromBody] ChangeStoredCardPlanStatusRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new ChangeStoredCardPlanStatusCommand
{
StoreId = storeId,
PlanId = StoreApiHelpers.ParseRequiredSnowflake(request.PlanId, nameof(request.PlanId)),
Status = request.Status
}, cancellationToken);
return ApiResponse<StoredCardPlanResponse>.Ok(MapPlan(result));
}
/// <summary>
/// 删除储值卡方案。
/// </summary>
[HttpPost("plan/delete")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> DeletePlan(
[FromBody] DeleteStoredCardPlanRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
await mediator.Send(new DeleteStoredCardPlanCommand
{
StoreId = storeId,
PlanId = StoreApiHelpers.ParseRequiredSnowflake(request.PlanId, nameof(request.PlanId))
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 获取充值记录分页。
/// </summary>
[HttpGet("record/list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<StoredCardRechargeRecordListResultResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoredCardRechargeRecordListResultResponse>> RecordList(
[FromQuery] StoredCardRechargeRecordListRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new GetStoredCardRechargeRecordListQuery
{
StoreId = storeId,
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<StoredCardRechargeRecordListResultResponse>.Ok(new StoredCardRechargeRecordListResultResponse
{
Items = result.Items.Select(MapRecord).ToList(),
Page = result.Page,
PageSize = result.PageSize,
TotalCount = result.TotalCount
});
}
/// <summary>
/// 导出充值记录 CSV。
/// </summary>
[HttpGet("record/export")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<StoredCardRechargeRecordExportResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoredCardRechargeRecordExportResponse>> ExportRecord(
[FromQuery] ExportStoredCardRechargeRecordRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new ExportStoredCardRechargeRecordCsvQuery
{
StoreId = storeId,
StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
Keyword = request.Keyword
}, cancellationToken);
return ApiResponse<StoredCardRechargeRecordExportResponse>.Ok(new StoredCardRechargeRecordExportResponse
{
FileName = result.FileName,
FileContentBase64 = result.FileContentBase64,
TotalCount = result.TotalCount
});
}
/// <summary>
/// 写入充值记录。
/// </summary>
[HttpPost("record/write")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<StoredCardRechargeRecordResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<StoredCardRechargeRecordResponse>> WriteRecord(
[FromBody] WriteStoredCardRechargeRecordRequest request,
CancellationToken cancellationToken)
{
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
var result = await mediator.Send(new WriteStoredCardRechargeRecordCommand
{
StoreId = storeId,
MemberId = StoreApiHelpers.ParseRequiredSnowflake(request.MemberId, nameof(request.MemberId)),
PlanId = StoreApiHelpers.ParseSnowflakeOrNull(request.PlanId),
RechargeAmount = request.RechargeAmount,
GiftAmount = request.GiftAmount,
PaymentMethod = request.PaymentMethod,
RechargedAt = request.RechargedAt,
Remark = request.Remark
}, cancellationToken);
return ApiResponse<StoredCardRechargeRecordResponse>.Ok(MapRecord(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 StoredCardPlanResponse MapPlan(MemberStoredCardPlanDto source)
{
return new StoredCardPlanResponse
{
PlanId = source.PlanId.ToString(),
RechargeAmount = source.RechargeAmount,
GiftAmount = source.GiftAmount,
ArrivedAmount = source.ArrivedAmount,
SortOrder = source.SortOrder,
Status = source.Status,
RechargeCount = source.RechargeCount,
TotalRechargeAmount = source.TotalRechargeAmount
};
}
private static StoredCardRechargeRecordResponse MapRecord(MemberStoredCardRechargeRecordDto source)
{
return new StoredCardRechargeRecordResponse
{
RecordId = source.RecordId.ToString(),
RecordNo = source.RecordNo,
MemberId = source.MemberId.ToString(),
MemberName = source.MemberName,
MemberMobileMasked = source.MemberMobileMasked,
RechargeAmount = source.RechargeAmount,
GiftAmount = source.GiftAmount,
ArrivedAmount = source.ArrivedAmount,
PaymentMethod = source.PaymentMethod,
PaymentMethodText = ResolvePaymentMethodText(source.PaymentMethod),
RechargedAt = source.RechargedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
PlanId = source.PlanId?.ToString(),
Remark = source.Remark
};
}
private static string ResolvePaymentMethodText(string paymentMethod)
{
return (paymentMethod ?? string.Empty).Trim().ToLowerInvariant() switch
{
"wechat" => "微信支付",
"alipay" => "支付宝",
"cash" => "现金",
"card" => "刷卡",
"balance" => "余额",
_ => "未知"
};
}
}

View File

@@ -0,0 +1,330 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Application.App.Members.Commands;
using TakeoutSaaS.Application.App.Members.Dto;
using TakeoutSaaS.Application.App.Members.Queries;
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.Exceptions;
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/tier")]
public sealed class MemberTierController(
IMediator mediator,
TakeoutAppDbContext dbContext,
StoreContextService storeContextService)
: BaseApiController
{
private const string ViewPermission = "tenant:member:view";
private const string ManagePermission = "tenant:member:manage";
/// <summary>
/// 获取会员等级列表。
/// </summary>
[HttpGet("list")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<List<MemberTierListItemResponse>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<List<MemberTierListItemResponse>>> List(CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMemberTierListQuery(), cancellationToken);
return ApiResponse<List<MemberTierListItemResponse>>.Ok(result.Select(MapTierListItem).ToList());
}
/// <summary>
/// 获取会员等级详情。
/// </summary>
[HttpGet("detail")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberTierDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberTierDetailResponse>> Detail(
[FromQuery] MemberTierDetailRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMemberTierDetailQuery
{
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId)
}, cancellationToken);
return ApiResponse<MemberTierDetailResponse>.Ok(MapTierDetail(result));
}
/// <summary>
/// 保存会员等级。
/// </summary>
[HttpPost("save")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberTierDetailResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberTierDetailResponse>> Save(
[FromBody] SaveMemberTierRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new SaveMemberTierCommand
{
TierId = StoreApiHelpers.ParseSnowflakeOrNull(request.TierId),
SortOrder = request.SortOrder,
Name = request.Name,
IconKey = request.IconKey,
ColorHex = request.ColorHex,
IsDefault = request.IsDefault,
Rule = new MemberTierRuleDto
{
UpgradeRuleType = request.Rule.UpgradeRuleType,
UpgradeAmountThreshold = request.Rule.UpgradeAmountThreshold,
UpgradeOrderCountThreshold = request.Rule.UpgradeOrderCountThreshold,
DowngradeWindowDays = request.Rule.DowngradeWindowDays
},
Benefits = MapBenefits(request.Benefits)
}, cancellationToken);
return ApiResponse<MemberTierDetailResponse>.Ok(MapTierDetail(result));
}
/// <summary>
/// 删除会员等级。
/// </summary>
[HttpPost("delete")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> Delete(
[FromBody] DeleteMemberTierRequest request,
CancellationToken cancellationToken)
{
await mediator.Send(new DeleteMemberTierCommand
{
TierId = StoreApiHelpers.ParseRequiredSnowflake(request.TierId, nameof(request.TierId))
}, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 获取会员日配置。
/// </summary>
[HttpGet("day-setting")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberDaySettingResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberDaySettingResponse>> GetDaySetting(CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMemberDaySettingQuery(), cancellationToken);
return ApiResponse<MemberDaySettingResponse>.Ok(new MemberDaySettingResponse
{
IsEnabled = result.IsEnabled,
Weekday = result.Weekday,
ExtraDiscountRate = result.ExtraDiscountRate
});
}
/// <summary>
/// 保存会员日配置。
/// </summary>
[HttpPost("day-setting")]
[PermissionAuthorize(ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<MemberDaySettingResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MemberDaySettingResponse>> SaveDaySetting(
[FromBody] SaveMemberDaySettingRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new SaveMemberDaySettingCommand
{
IsEnabled = request.IsEnabled,
Weekday = request.Weekday,
ExtraDiscountRate = request.ExtraDiscountRate
}, cancellationToken);
return ApiResponse<MemberDaySettingResponse>.Ok(new MemberDaySettingResponse
{
IsEnabled = result.IsEnabled,
Weekday = result.Weekday,
ExtraDiscountRate = result.ExtraDiscountRate
});
}
/// <summary>
/// 查询可选优惠券列表。
/// </summary>
[HttpGet("coupon-picker")]
[PermissionAuthorize(ViewPermission, ManagePermission)]
[ProducesResponseType(typeof(ApiResponse<List<MemberCouponPickerItemResponse>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<List<MemberCouponPickerItemResponse>>> CouponPicker(
[FromQuery] MemberCouponPickerRequest request,
CancellationToken cancellationToken)
{
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
var result = await mediator.Send(new SearchMemberCouponPickerQuery
{
VisibleStoreIds = visibleStoreIds,
Keyword = request.Keyword
}, cancellationToken);
return ApiResponse<List<MemberCouponPickerItemResponse>>.Ok(result.Select(item => new MemberCouponPickerItemResponse
{
CouponTemplateId = item.CouponTemplateId.ToString(),
Name = item.Name,
CouponType = item.CouponType,
Value = item.Value,
MinimumSpend = item.MinimumSpend,
DisplayText = item.DisplayText
}).ToList());
}
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
string? storeId,
CancellationToken cancellationToken)
{
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
if (!string.IsNullOrWhiteSpace(storeId))
{
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
await StoreApiHelpers.EnsureStoreAccessibleAsync(
dbContext,
tenantId,
merchantId,
parsedStoreId,
cancellationToken);
return [parsedStoreId];
}
var allStoreIds = await dbContext.Stores
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.MerchantId == merchantId)
.Select(item => item.Id)
.OrderBy(item => item)
.ToListAsync(cancellationToken);
if (allStoreIds.Count == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
}
return allStoreIds;
}
private static MemberTierListItemResponse MapTierListItem(MemberTierListItemDto source)
{
return new MemberTierListItemResponse
{
TierId = source.TierId.ToString(),
SortOrder = source.SortOrder,
Name = source.Name,
IconKey = source.IconKey,
ColorHex = source.ColorHex,
ConditionText = source.ConditionText,
Perks = source.Perks.ToList(),
MemberCount = source.MemberCount,
IsDefault = source.IsDefault,
CanDelete = source.CanDelete
};
}
private static MemberTierDetailResponse MapTierDetail(MemberTierDetailDto source)
{
return new MemberTierDetailResponse
{
TierId = source.TierId?.ToString(),
SortOrder = source.SortOrder,
Name = source.Name,
IconKey = source.IconKey,
ColorHex = source.ColorHex,
IsDefault = source.IsDefault,
Rule = new MemberTierRuleResponse
{
UpgradeRuleType = source.Rule.UpgradeRuleType,
UpgradeAmountThreshold = source.Rule.UpgradeAmountThreshold,
UpgradeOrderCountThreshold = source.Rule.UpgradeOrderCountThreshold,
DowngradeWindowDays = source.Rule.DowngradeWindowDays
},
Benefits = new MemberTierBenefitsResponse
{
Discount = new MemberTierDiscountBenefitResponse
{
Enabled = source.Benefits.Discount.Enabled,
DiscountRate = source.Benefits.Discount.DiscountRate
},
PointMultiplier = new MemberTierPointMultiplierBenefitResponse
{
Enabled = source.Benefits.PointMultiplier.Enabled,
Multiplier = source.Benefits.PointMultiplier.Multiplier
},
Birthday = new MemberTierBirthdayBenefitResponse
{
Enabled = source.Benefits.Birthday.Enabled,
DoublePointsEnabled = source.Benefits.Birthday.DoublePointsEnabled,
CouponTemplateIds = source.Benefits.Birthday.CouponTemplateIds.Select(item => item.ToString()).ToList()
},
MonthlyCoupon = new MemberTierMonthlyCouponBenefitResponse
{
Enabled = source.Benefits.MonthlyCoupon.Enabled,
GrantDay = source.Benefits.MonthlyCoupon.GrantDay,
CouponTemplateIds = source.Benefits.MonthlyCoupon.CouponTemplateIds.Select(item => item.ToString()).ToList()
},
FreeDelivery = new MemberTierFreeDeliveryBenefitResponse
{
Enabled = source.Benefits.FreeDelivery.Enabled,
MonthlyFreeTimes = source.Benefits.FreeDelivery.MonthlyFreeTimes
},
PriorityDeliveryEnabled = source.Benefits.PriorityDeliveryEnabled,
ExclusiveServiceEnabled = source.Benefits.ExclusiveServiceEnabled
},
CanDelete = source.CanDelete
};
}
private static MemberTierBenefitsDto MapBenefits(MemberTierBenefitsResponse source)
{
return new MemberTierBenefitsDto
{
Discount = new MemberTierDiscountBenefitDto
{
Enabled = source.Discount.Enabled,
DiscountRate = source.Discount.DiscountRate
},
PointMultiplier = new MemberTierPointMultiplierBenefitDto
{
Enabled = source.PointMultiplier.Enabled,
Multiplier = source.PointMultiplier.Multiplier
},
Birthday = new MemberTierBirthdayBenefitDto
{
Enabled = source.Birthday.Enabled,
DoublePointsEnabled = source.Birthday.DoublePointsEnabled,
CouponTemplateIds = source.Birthday.CouponTemplateIds
.Select(StoreApiHelpers.ParseSnowflakeOrNull)
.Where(item => item.HasValue)
.Select(item => item!.Value)
.ToList()
},
MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto
{
Enabled = source.MonthlyCoupon.Enabled,
GrantDay = source.MonthlyCoupon.GrantDay,
CouponTemplateIds = source.MonthlyCoupon.CouponTemplateIds
.Select(StoreApiHelpers.ParseSnowflakeOrNull)
.Where(item => item.HasValue)
.Select(item => item!.Value)
.ToList()
},
FreeDelivery = new MemberTierFreeDeliveryBenefitDto
{
Enabled = source.FreeDelivery.Enabled,
MonthlyFreeTimes = source.FreeDelivery.MonthlyFreeTimes
},
PriorityDeliveryEnabled = source.PriorityDeliveryEnabled,
ExclusiveServiceEnabled = source.ExclusiveServiceEnabled
};
}
}

View File

@@ -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),

View File

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

View File

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

View File

@@ -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,

View File

@@ -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,

View File

@@ -0,0 +1,431 @@
namespace TakeoutSaaS.Application.App.Customers.Dto;
/// <summary>
/// 客户分析增长趋势点 DTO。
/// </summary>
public sealed class CustomerAnalysisTrendPointDto
{
/// <summary>
/// 维度标签。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 数量值。
/// </summary>
public int Value { get; init; }
}
/// <summary>
/// 客户分析新老客构成项 DTO。
/// </summary>
public sealed class CustomerAnalysisCompositionItemDto
{
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; init; } = string.Empty;
/// <summary>
/// 分群名称。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 分群人数。
/// </summary>
public int Count { get; init; }
/// <summary>
/// 分群占比(百分比)。
/// </summary>
public decimal Percent { get; init; }
/// <summary>
/// 色调blue/green/orange/gray
/// </summary>
public string Tone { get; init; } = "blue";
}
/// <summary>
/// 客单价分布项 DTO。
/// </summary>
public sealed class CustomerAnalysisAmountDistributionItemDto
{
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; init; } = string.Empty;
/// <summary>
/// 区间标签。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 人数。
/// </summary>
public int Count { get; init; }
/// <summary>
/// 占比(百分比)。
/// </summary>
public decimal Percent { get; init; }
}
/// <summary>
/// RFM 分层单元 DTO。
/// </summary>
public sealed class CustomerAnalysisRfmCellDto
{
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; init; } = string.Empty;
/// <summary>
/// 分层标签。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 人数。
/// </summary>
public int Count { get; init; }
/// <summary>
/// 温度hot/warm/cool/cold
/// </summary>
public string Tone { get; init; } = "cold";
}
/// <summary>
/// RFM 分层行 DTO。
/// </summary>
public sealed class CustomerAnalysisRfmRowDto
{
/// <summary>
/// 行标签。
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// 单元格集合。
/// </summary>
public IReadOnlyList<CustomerAnalysisRfmCellDto> Cells { get; init; } = [];
}
/// <summary>
/// 高价值客户 DTO。
/// </summary>
public sealed class CustomerAnalysisTopCustomerDto
{
/// <summary>
/// 排名。
/// </summary>
public int Rank { get; init; }
/// <summary>
/// 客户标识。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; init; } = string.Empty;
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 下单次数。
/// </summary>
public int OrderCount { get; init; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; init; }
/// <summary>
/// 最近下单时间。
/// </summary>
public DateTime LastOrderAt { get; init; }
/// <summary>
/// 客户标签。
/// </summary>
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
}
/// <summary>
/// 客户分析总览 DTO。
/// </summary>
public sealed class CustomerAnalysisOverviewDto
{
/// <summary>
/// 统计周期编码。
/// </summary>
public string PeriodCode { get; init; } = "30d";
/// <summary>
/// 统计周期天数。
/// </summary>
public int PeriodDays { get; init; } = 30;
/// <summary>
/// 客户总数。
/// </summary>
public int TotalCustomers { get; init; }
/// <summary>
/// 周期新增客户数。
/// </summary>
public int NewCustomers { get; init; }
/// <summary>
/// 新增较上一周期增长百分比。
/// </summary>
public decimal GrowthRatePercent { get; init; }
/// <summary>
/// 周期内日均新增客户。
/// </summary>
public decimal NewCustomersDailyAverage { get; init; }
/// <summary>
/// 活跃客户数。
/// </summary>
public int ActiveCustomers { get; init; }
/// <summary>
/// 活跃率(百分比)。
/// </summary>
public decimal ActiveRatePercent { get; init; }
/// <summary>
/// 平均客户价值(累计消费均值)。
/// </summary>
public decimal AverageLifetimeValue { get; init; }
/// <summary>
/// 客户增长趋势。
/// </summary>
public IReadOnlyList<CustomerAnalysisTrendPointDto> GrowthTrend { get; init; } = [];
/// <summary>
/// 新老客占比。
/// </summary>
public IReadOnlyList<CustomerAnalysisCompositionItemDto> Composition { get; init; } = [];
/// <summary>
/// 客单价分布。
/// </summary>
public IReadOnlyList<CustomerAnalysisAmountDistributionItemDto> AmountDistribution { get; init; } = [];
/// <summary>
/// RFM 分层。
/// </summary>
public IReadOnlyList<CustomerAnalysisRfmRowDto> RfmRows { get; init; } = [];
/// <summary>
/// 高价值客户 Top10。
/// </summary>
public IReadOnlyList<CustomerAnalysisTopCustomerDto> TopCustomers { get; init; } = [];
}
/// <summary>
/// 客群明细行 DTO。
/// </summary>
public sealed class CustomerAnalysisSegmentListItemDto
{
/// <summary>
/// 客户标识。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; init; } = string.Empty;
/// <summary>
/// 头像文案。
/// </summary>
public string AvatarText { get; init; } = string.Empty;
/// <summary>
/// 头像颜色。
/// </summary>
public string AvatarColor { get; init; } = string.Empty;
/// <summary>
/// 客户标签。
/// </summary>
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
/// <summary>
/// 是否会员。
/// </summary>
public bool IsMember { get; init; }
/// <summary>
/// 会员等级。
/// </summary>
public string MemberTierName { get; init; } = string.Empty;
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 下单次数。
/// </summary>
public int OrderCount { get; init; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; init; }
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; init; }
/// <summary>
/// 最近下单时间。
/// </summary>
public DateTime LastOrderAt { get; init; }
/// <summary>
/// 是否弱化显示。
/// </summary>
public bool IsDimmed { get; init; }
}
/// <summary>
/// 客群明细结果 DTO。
/// </summary>
public sealed class CustomerAnalysisSegmentListResultDto
{
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; init; } = string.Empty;
/// <summary>
/// 分群标题。
/// </summary>
public string SegmentTitle { get; init; } = string.Empty;
/// <summary>
/// 分群说明。
/// </summary>
public string SegmentDescription { get; init; } = string.Empty;
/// <summary>
/// 列表项。
/// </summary>
public IReadOnlyList<CustomerAnalysisSegmentListItemDto> Items { get; init; } = [];
/// <summary>
/// 当前页。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
/// <summary>
/// 总记录数。
/// </summary>
public int TotalCount { get; init; }
}
/// <summary>
/// 会员详情 DTO。
/// </summary>
public sealed class CustomerMemberDetailDto
{
/// <summary>
/// 客户标识。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
/// <summary>
/// 客户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string PhoneMasked { get; init; } = string.Empty;
/// <summary>
/// 来源。
/// </summary>
public string Source { get; init; } = string.Empty;
/// <summary>
/// 注册时间。
/// </summary>
public DateTime RegisteredAt { get; init; }
/// <summary>
/// 最近下单时间。
/// </summary>
public DateTime LastOrderAt { get; init; }
/// <summary>
/// 会员摘要。
/// </summary>
public CustomerMemberSummaryDto Member { get; init; } = new();
/// <summary>
/// 客户标签。
/// </summary>
public IReadOnlyList<CustomerTagDto> Tags { get; init; } = [];
/// <summary>
/// 累计下单次数。
/// </summary>
public int TotalOrders { get; init; }
/// <summary>
/// 累计消费。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageAmount { get; init; }
/// <summary>
/// 复购率。
/// </summary>
public decimal RepurchaseRatePercent { get; init; }
/// <summary>
/// 最近订单。
/// </summary>
public IReadOnlyList<CustomerRecentOrderDto> RecentOrders { get; init; } = [];
}

View File

@@ -0,0 +1,478 @@
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
internal static class CustomerAnalysisSegmentSupport
{
internal const string SegmentAll = "all";
internal const string SegmentRepeatLoyal = "repeat_loyal";
internal const string SegmentActiveNew = "active_new";
internal const string SegmentActiveRecent = "active_recent";
internal const string SegmentDormant = "dormant";
internal const string SegmentChurn = "churn";
internal const string SegmentHighValueTop = "high_value_top";
private static readonly string[] CompositionSegmentOrder =
[
SegmentRepeatLoyal,
SegmentActiveNew,
SegmentDormant,
SegmentChurn
];
private static readonly (string SegmentCode, string Label, string Tone)[] CompositionDefinitions =
[
(SegmentRepeatLoyal, "老客户复购2次+", "blue"),
(SegmentActiveNew, "活跃新客", "green"),
(SegmentDormant, "沉睡客户", "orange"),
(SegmentChurn, "流失客户", "gray")
];
private static readonly AmountDistributionDefinition[] AmountDistributionDefinitions =
[
new("amount_0_30", "0 - 30元", 0m, 30m),
new("amount_30_60", "30 - 60元", 30m, 60m),
new("amount_60_100", "60 - 100元", 60m, 100m),
new("amount_100_150", "100 - 150元", 100m, 150m),
new("amount_150_plus", "150元以上", 150m, null)
];
private static readonly string[] RfmRowLabels =
[
"近期活跃",
"中期沉默",
"长期流失"
];
private static readonly string[] RfmColumnLabels =
[
"高频高额",
"高频低额",
"低频高额",
"低频低额"
];
private static readonly string[,] RfmCellLabels =
{
{ "重要价值", "潜力客户", "新客培育", "一般维护" },
{ "重要挽留", "一般发展", "一般保持", "低优先级" },
{ "重要召回", "即将流失", "基本流失", "已流失" }
};
private static readonly string[,] RfmCellTones =
{
{ "hot", "warm", "warm", "cool" },
{ "warm", "cool", "cool", "cold" },
{ "cool", "cold", "cold", "cold" }
};
internal static string NormalizeSegmentCode(string? segmentCode)
{
var normalized = (segmentCode ?? string.Empty).Trim().ToLowerInvariant();
return string.IsNullOrWhiteSpace(normalized) ? SegmentAll : normalized;
}
internal static SegmentMeta ResolveSegmentMeta(string normalizedSegmentCode)
{
var amountDefinition = ResolveAmountDistributionDefinition(normalizedSegmentCode);
if (amountDefinition is not null)
{
return new SegmentMeta(
normalizedSegmentCode,
$"客单价分布 · {amountDefinition.Label}",
$"筛选客单价位于 {amountDefinition.Label} 区间的客户");
}
if (TryParseRfmSegmentCode(normalizedSegmentCode, out var rowIndex, out var columnIndex))
{
return new SegmentMeta(
normalizedSegmentCode,
$"RFM分层 · {RfmRowLabels[rowIndex]} / {RfmColumnLabels[columnIndex]}",
$"当前客群标签:{RfmCellLabels[rowIndex, columnIndex]}");
}
return normalizedSegmentCode switch
{
SegmentAll => new SegmentMeta(SegmentAll, "全部客户", "当前门店下全部客户明细"),
SegmentRepeatLoyal => new SegmentMeta(SegmentRepeatLoyal, "老客户复购2次+", "非流失/沉睡且非新客的稳定复购客户"),
SegmentActiveNew => new SegmentMeta(SegmentActiveNew, "活跃新客", "统计周期内注册且有消费的客户"),
SegmentActiveRecent => new SegmentMeta(SegmentActiveRecent, "周期活跃客户", "统计周期内发生过消费行为的客户"),
SegmentDormant => new SegmentMeta(SegmentDormant, "沉睡客户", "31-60天未消费的客户"),
SegmentChurn => new SegmentMeta(SegmentChurn, "流失客户", "超过60天未消费的客户"),
SegmentHighValueTop => new SegmentMeta(SegmentHighValueTop, "高价值客户", "累计消费或消费能力达到高价值阈值的客户"),
_ => throw new BusinessException(ErrorCodes.BadRequest, "segmentCode 参数不合法")
};
}
internal static IReadOnlyList<CustomerAnalysisCompositionItemDto> BuildComposition(
IReadOnlyList<CustomerAggregate> customers,
DateTime nowUtc,
int periodDays)
{
if (customers.Count == 0)
{
return CompositionDefinitions
.Select(item => new CustomerAnalysisCompositionItemDto
{
SegmentCode = item.SegmentCode,
Label = item.Label,
Count = 0,
Percent = 0,
Tone = item.Tone
})
.ToList();
}
var counter = CompositionSegmentOrder.ToDictionary(item => item, _ => 0, StringComparer.Ordinal);
foreach (var customer in customers)
{
var segmentCode = ResolveCompositionSegmentCode(customer, nowUtc, periodDays);
counter[segmentCode] += 1;
}
return CompositionDefinitions
.Select(item => new CustomerAnalysisCompositionItemDto
{
SegmentCode = item.SegmentCode,
Label = item.Label,
Count = counter[item.SegmentCode],
Percent = CustomerAnalyticsSupport.ToRatePercent(counter[item.SegmentCode], customers.Count),
Tone = item.Tone
})
.ToList();
}
internal static IReadOnlyList<CustomerAnalysisAmountDistributionItemDto> BuildAmountDistribution(
IReadOnlyList<CustomerAggregate> customers)
{
if (customers.Count == 0)
{
return AmountDistributionDefinitions
.Select(item => new CustomerAnalysisAmountDistributionItemDto
{
SegmentCode = item.SegmentCode,
Label = item.Label,
Count = 0,
Percent = 0
})
.ToList();
}
return AmountDistributionDefinitions
.Select(item =>
{
var count = customers.Count(customer => MatchAmountDistribution(customer.AverageAmount, item));
return new CustomerAnalysisAmountDistributionItemDto
{
SegmentCode = item.SegmentCode,
Label = item.Label,
Count = count,
Percent = CustomerAnalyticsSupport.ToRatePercent(count, customers.Count)
};
})
.ToList();
}
internal static IReadOnlyList<CustomerAnalysisRfmRowDto> BuildRfmRows(
IReadOnlyList<CustomerAggregate> customers,
DateTime nowUtc)
{
var counters = new int[3, 4];
foreach (var customer in customers)
{
var rowIndex = ResolveRfmRecencyRow(customer, nowUtc);
var columnIndex = ResolveRfmFrequencyAmountColumn(customer);
counters[rowIndex, columnIndex] += 1;
}
var rows = new List<CustomerAnalysisRfmRowDto>(3);
for (var rowIndex = 0; rowIndex < 3; rowIndex += 1)
{
var cells = new List<CustomerAnalysisRfmCellDto>(4);
for (var columnIndex = 0; columnIndex < 4; columnIndex += 1)
{
cells.Add(new CustomerAnalysisRfmCellDto
{
SegmentCode = BuildRfmSegmentCode(rowIndex, columnIndex),
Label = RfmCellLabels[rowIndex, columnIndex],
Count = counters[rowIndex, columnIndex],
Tone = RfmCellTones[rowIndex, columnIndex]
});
}
rows.Add(new CustomerAnalysisRfmRowDto
{
Label = RfmRowLabels[rowIndex],
Cells = cells
});
}
return rows;
}
internal static IReadOnlyList<CustomerAnalysisTrendPointDto> BuildGrowthTrend(
IReadOnlyList<CustomerAggregate> customers,
DateTime nowUtc,
int monthCount)
{
var normalizedMonthCount = Math.Clamp(monthCount, 1, 24);
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var windowStart = monthStart.AddMonths(-normalizedMonthCount + 1);
var countLookup = customers
.Where(item => item.RegisteredAt >= windowStart && item.RegisteredAt < monthStart.AddMonths(1))
.GroupBy(item => new DateTime(item.RegisteredAt.Year, item.RegisteredAt.Month, 1, 0, 0, 0, DateTimeKind.Utc))
.ToDictionary(group => group.Key, group => group.Count());
var trend = new List<CustomerAnalysisTrendPointDto>(normalizedMonthCount);
for (var index = 0; index < normalizedMonthCount; index += 1)
{
var currentMonth = windowStart.AddMonths(index);
countLookup.TryGetValue(currentMonth, out var value);
trend.Add(new CustomerAnalysisTrendPointDto
{
Label = $"{currentMonth.Month}月",
Value = value
});
}
return trend;
}
internal static IReadOnlyList<CustomerAnalysisTopCustomerDto> BuildTopCustomers(
IReadOnlyList<CustomerAggregate> customers,
int takeCount)
{
var normalizedTakeCount = Math.Clamp(takeCount, 1, 200);
return customers
.OrderByDescending(item => item.TotalAmount)
.ThenByDescending(item => item.OrderCount)
.ThenByDescending(item => item.LastOrderAt)
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
.Take(normalizedTakeCount)
.Select((item, index) => new CustomerAnalysisTopCustomerDto
{
Rank = index + 1,
CustomerKey = item.CustomerKey,
Name = item.Name,
PhoneMasked = item.PhoneMasked,
TotalAmount = item.TotalAmount,
OrderCount = item.OrderCount,
AverageAmount = item.AverageAmount,
LastOrderAt = item.LastOrderAt,
Tags = item.Tags
})
.ToList();
}
internal static IReadOnlyList<CustomerAggregate> FilterBySegment(
IReadOnlyList<CustomerAggregate> customers,
string normalizedSegmentCode,
DateTime nowUtc,
int periodDays)
{
if (normalizedSegmentCode == SegmentAll)
{
return customers;
}
if (TryParseRfmSegmentCode(normalizedSegmentCode, out var rowIndex, out var columnIndex))
{
return customers
.Where(customer =>
ResolveRfmRecencyRow(customer, nowUtc) == rowIndex &&
ResolveRfmFrequencyAmountColumn(customer) == columnIndex)
.ToList();
}
var amountDefinition = ResolveAmountDistributionDefinition(normalizedSegmentCode);
if (amountDefinition is not null)
{
return customers
.Where(customer => MatchAmountDistribution(customer.AverageAmount, amountDefinition))
.ToList();
}
return normalizedSegmentCode switch
{
SegmentRepeatLoyal => customers
.Where(customer => string.Equals(
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
SegmentRepeatLoyal,
StringComparison.Ordinal))
.ToList(),
SegmentActiveNew => customers
.Where(customer => string.Equals(
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
SegmentActiveNew,
StringComparison.Ordinal))
.ToList(),
SegmentActiveRecent => customers
.Where(customer => customer.LastOrderAt >= nowUtc.AddDays(-Math.Clamp(periodDays, 7, 365)))
.ToList(),
SegmentDormant => customers
.Where(customer => string.Equals(
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
SegmentDormant,
StringComparison.Ordinal))
.ToList(),
SegmentChurn => customers
.Where(customer => string.Equals(
ResolveCompositionSegmentCode(customer, nowUtc, periodDays),
SegmentChurn,
StringComparison.Ordinal))
.ToList(),
SegmentHighValueTop => customers
.Where(customer =>
customer.Tags.Any(tag => string.Equals(tag.Code, CustomerAnalyticsSupport.TagHighValue, StringComparison.Ordinal)))
.ToList(),
_ => throw new BusinessException(ErrorCodes.BadRequest, "segmentCode 参数不合法")
};
}
internal static IReadOnlyList<CustomerAggregate> ApplyKeyword(
IReadOnlyList<CustomerAggregate> customers,
string? keyword)
{
var normalizedKeyword = (keyword ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedKeyword))
{
return customers;
}
var keywordDigits = CustomerAnalyticsSupport.NormalizePhone(normalizedKeyword);
return customers
.Where(customer =>
{
var matchedByName = customer.Name.Contains(normalizedKeyword, StringComparison.OrdinalIgnoreCase);
var matchedByPhone = !string.IsNullOrWhiteSpace(keywordDigits) &&
customer.CustomerKey.Contains(keywordDigits, StringComparison.Ordinal);
return matchedByName || matchedByPhone;
})
.ToList();
}
internal static bool TryParseRfmSegmentCode(
string normalizedSegmentCode,
out int rowIndex,
out int columnIndex)
{
rowIndex = -1;
columnIndex = -1;
if (!normalizedSegmentCode.StartsWith("rfm_r", StringComparison.Ordinal) ||
normalizedSegmentCode.Length != 8)
{
return false;
}
var rowChar = normalizedSegmentCode[5];
var separator = normalizedSegmentCode[6];
var columnChar = normalizedSegmentCode[7];
if (separator != 'c' || !char.IsDigit(rowChar) || !char.IsDigit(columnChar))
{
return false;
}
rowIndex = rowChar - '1';
columnIndex = columnChar - '1';
return rowIndex is >= 0 and < 3 && columnIndex is >= 0 and < 4;
}
internal static string BuildRfmSegmentCode(int rowIndex, int columnIndex)
{
return $"rfm_r{rowIndex + 1}c{columnIndex + 1}";
}
private static string ResolveCompositionSegmentCode(
CustomerAggregate customer,
DateTime nowUtc,
int periodDays)
{
var silentDays = (nowUtc.Date - customer.LastOrderAt.Date).TotalDays;
if (silentDays > 60)
{
return SegmentChurn;
}
if (silentDays > 30)
{
return SegmentDormant;
}
if (customer.RegisteredAt >= nowUtc.AddDays(-Math.Clamp(periodDays, 7, 365)))
{
return SegmentActiveNew;
}
return SegmentRepeatLoyal;
}
private static AmountDistributionDefinition? ResolveAmountDistributionDefinition(string normalizedSegmentCode)
{
return AmountDistributionDefinitions.FirstOrDefault(item =>
string.Equals(item.SegmentCode, normalizedSegmentCode, StringComparison.Ordinal));
}
private static bool MatchAmountDistribution(decimal amount, AmountDistributionDefinition definition)
{
if (amount < definition.MinInclusive)
{
return false;
}
if (definition.MaxExclusive.HasValue && amount >= definition.MaxExclusive.Value)
{
return false;
}
return true;
}
private static int ResolveRfmRecencyRow(CustomerAggregate customer, DateTime nowUtc)
{
var silentDays = (nowUtc.Date - customer.LastOrderAt.Date).TotalDays;
if (silentDays <= 30)
{
return 0;
}
if (silentDays <= 60)
{
return 1;
}
return 2;
}
private static int ResolveRfmFrequencyAmountColumn(CustomerAggregate customer)
{
var highFrequency = customer.OrderCount >= 10;
var highAmount = customer.AverageAmount >= 100m;
return (highFrequency, highAmount) switch
{
(true, true) => 0,
(true, false) => 1,
(false, true) => 2,
_ => 3
};
}
private sealed record AmountDistributionDefinition(
string SegmentCode,
string Label,
decimal MinInclusive,
decimal? MaxExclusive);
}
internal sealed record SegmentMeta(
string SegmentCode,
string SegmentTitle,
string SegmentDescription);

View File

@@ -0,0 +1,187 @@
using System.Globalization;
using System.Text;
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 客户分析报表导出处理器。
/// </summary>
public sealed class ExportCustomerAnalysisCsvQueryHandler(
IOrderRepository orderRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<ExportCustomerAnalysisCsvQuery, CustomerExportDto>
{
/// <inheritdoc />
public async Task<CustomerExportDto> Handle(
ExportCustomerAnalysisCsvQuery request,
CancellationToken cancellationToken)
{
var periodCode = string.IsNullOrWhiteSpace(request.PeriodCode)
? "30d"
: request.PeriodCode.Trim().ToLowerInvariant();
var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
if (request.VisibleStoreIds.Count == 0)
{
return BuildExport(
BuildCsv(periodCode, periodDays, [], DateTime.UtcNow),
0);
}
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
return BuildExport(
BuildCsv(periodCode, periodDays, customers, DateTime.UtcNow),
Math.Min(10, customers.Count));
}
private static CustomerExportDto BuildExport(string csv, int totalCount)
{
var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
return new CustomerExportDto
{
FileName = $"客户分析报表_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
FileContentBase64 = Convert.ToBase64String(bytes),
TotalCount = totalCount
};
}
private static string BuildCsv(
string periodCode,
int periodDays,
IReadOnlyList<CustomerAggregate> customers,
DateTime nowUtc)
{
var currentStart = nowUtc.AddDays(-periodDays);
var previousStart = currentStart.AddDays(-periodDays);
var totalCustomers = customers.Count;
var newCustomers = customers.Count(item => item.RegisteredAt >= currentStart);
var previousNewCustomers = customers.Count(item =>
item.RegisteredAt >= previousStart &&
item.RegisteredAt < currentStart);
var activeCustomers = customers.Count(item => item.LastOrderAt >= currentStart);
var averageLifetimeValue = totalCustomers <= 0
? 0
: decimal.Round(
customers.Sum(item => item.TotalAmount) / totalCustomers,
2,
MidpointRounding.AwayFromZero);
var composition = CustomerAnalysisSegmentSupport.BuildComposition(customers, nowUtc, periodDays);
var amountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution(customers);
var rfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows(customers, nowUtc);
var topCustomers = CustomerAnalysisSegmentSupport.BuildTopCustomers(customers, 10);
var sb = new StringBuilder();
sb.AppendLine($"统计周期,{Escape(ResolvePeriodLabel(periodCode, periodDays))}");
sb.AppendLine();
sb.AppendLine("核心指标");
sb.AppendLine("指标,值");
sb.AppendLine($"客户总数,{totalCustomers}");
sb.AppendLine($"周期新增,{newCustomers}");
sb.AppendLine($"新增增长率,{CustomerAnalyticsSupport.ToGrowthRatePercent(newCustomers, previousNewCustomers):0.#}%");
sb.AppendLine($"周期活跃客户,{activeCustomers}");
sb.AppendLine($"活跃率,{CustomerAnalyticsSupport.ToRatePercent(activeCustomers, totalCustomers):0.#}%");
sb.AppendLine($"平均客户价值,{averageLifetimeValue.ToString("0.00", CultureInfo.InvariantCulture)}");
sb.AppendLine();
sb.AppendLine("新老客占比");
sb.AppendLine("分层,人数,占比");
foreach (var item in composition)
{
sb.AppendLine($"{Escape(item.Label)},{item.Count},{item.Percent:0.#}%");
}
sb.AppendLine();
sb.AppendLine("客单价分布");
sb.AppendLine("区间,人数,占比");
foreach (var item in amountDistribution)
{
sb.AppendLine($"{Escape(item.Label)},{item.Count},{item.Percent:0.#}%");
}
sb.AppendLine();
sb.AppendLine("RFM客户分层");
sb.AppendLine("活跃度,分群,标签,人数");
for (var rowIndex = 0; rowIndex < rfmRows.Count; rowIndex += 1)
{
var row = rfmRows[rowIndex];
for (var columnIndex = 0; columnIndex < row.Cells.Count; columnIndex += 1)
{
var cell = row.Cells[columnIndex];
sb.AppendLine($"{Escape(row.Label)},{Escape(ResolveRfmColumnLabel(columnIndex))},{Escape(cell.Label)},{cell.Count}");
}
}
sb.AppendLine();
sb.AppendLine("高价值客户TOP10");
sb.AppendLine("排名,客户,手机号,累计消费,下单次数,客单价,最近下单,标签");
foreach (var item in topCustomers)
{
var tags = item.Tags.Count == 0
? string.Empty
: string.Join('、', item.Tags.Select(tag => tag.Label));
sb.AppendLine(string.Join(',',
[
item.Rank.ToString(CultureInfo.InvariantCulture),
Escape(item.Name),
Escape(item.PhoneMasked),
Escape(item.TotalAmount.ToString("0.00", CultureInfo.InvariantCulture)),
Escape(item.OrderCount.ToString(CultureInfo.InvariantCulture)),
Escape(item.AverageAmount.ToString("0.00", CultureInfo.InvariantCulture)),
Escape(item.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
Escape(tags)
]));
}
return sb.ToString();
}
private static string ResolvePeriodLabel(string periodCode, int periodDays)
{
return periodCode switch
{
"7d" => "近7天",
"30d" => "近30天",
"90d" => "近90天",
"365d" => "近1年",
_ => $"近{periodDays}天"
};
}
private static string ResolveRfmColumnLabel(int columnIndex)
{
return columnIndex switch
{
0 => "高频高额",
1 => "高频低额",
2 => "低频高额",
_ => "低频低额"
};
}
private static string Escape(string input)
{
if (!input.Contains('"') && !input.Contains(',') && !input.Contains('\n') && !input.Contains('\r'))
{
return input;
}
return $"\"{input.Replace("\"", "\"\"")}\"";
}
}

View File

@@ -0,0 +1,94 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 客户分析总览查询处理器。
/// </summary>
public sealed class GetCustomerAnalysisOverviewQueryHandler(
IOrderRepository orderRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<GetCustomerAnalysisOverviewQuery, CustomerAnalysisOverviewDto>
{
/// <inheritdoc />
public async Task<CustomerAnalysisOverviewDto> Handle(
GetCustomerAnalysisOverviewQuery request,
CancellationToken cancellationToken)
{
var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
var periodCode = string.IsNullOrWhiteSpace(request.PeriodCode)
? "30d"
: request.PeriodCode.Trim().ToLowerInvariant();
if (request.VisibleStoreIds.Count == 0)
{
return BuildEmptyOverview(periodCode, periodDays);
}
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var nowUtc = DateTime.UtcNow;
var currentStart = nowUtc.AddDays(-periodDays);
var previousStart = currentStart.AddDays(-periodDays);
var totalCustomers = customers.Count;
var newCustomers = customers.Count(item => item.RegisteredAt >= currentStart);
var previousNewCustomers = customers.Count(item =>
item.RegisteredAt >= previousStart &&
item.RegisteredAt < currentStart);
var activeCustomers = customers.Count(item => item.LastOrderAt >= currentStart);
var averageLifetimeValue = totalCustomers <= 0
? 0
: decimal.Round(
customers.Sum(item => item.TotalAmount) / totalCustomers,
2,
MidpointRounding.AwayFromZero);
return new CustomerAnalysisOverviewDto
{
PeriodCode = periodCode,
PeriodDays = periodDays,
TotalCustomers = totalCustomers,
NewCustomers = newCustomers,
GrowthRatePercent = CustomerAnalyticsSupport.ToGrowthRatePercent(newCustomers, previousNewCustomers),
NewCustomersDailyAverage = decimal.Round(
newCustomers / Math.Max(1m, periodDays),
1,
MidpointRounding.AwayFromZero),
ActiveCustomers = activeCustomers,
ActiveRatePercent = CustomerAnalyticsSupport.ToRatePercent(activeCustomers, totalCustomers),
AverageLifetimeValue = averageLifetimeValue,
GrowthTrend = CustomerAnalysisSegmentSupport.BuildGrowthTrend(customers, nowUtc, 12),
Composition = CustomerAnalysisSegmentSupport.BuildComposition(customers, nowUtc, periodDays),
AmountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution(customers),
RfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows(customers, nowUtc),
TopCustomers = CustomerAnalysisSegmentSupport.BuildTopCustomers(customers, 10)
};
}
private static CustomerAnalysisOverviewDto BuildEmptyOverview(string periodCode, int periodDays)
{
return new CustomerAnalysisOverviewDto
{
PeriodCode = periodCode,
PeriodDays = periodDays,
GrowthTrend = CustomerAnalysisSegmentSupport.BuildGrowthTrend([], DateTime.UtcNow, 12),
Composition = CustomerAnalysisSegmentSupport.BuildComposition([], DateTime.UtcNow, periodDays),
AmountDistribution = CustomerAnalysisSegmentSupport.BuildAmountDistribution([]),
RfmRows = CustomerAnalysisSegmentSupport.BuildRfmRows([], DateTime.UtcNow),
TopCustomers = []
};
}
}

View File

@@ -0,0 +1,102 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 客群明细查询处理器。
/// </summary>
public sealed class GetCustomerAnalysisSegmentListQueryHandler(
IOrderRepository orderRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<GetCustomerAnalysisSegmentListQuery, CustomerAnalysisSegmentListResultDto>
{
/// <inheritdoc />
public async Task<CustomerAnalysisSegmentListResultDto> Handle(
GetCustomerAnalysisSegmentListQuery request,
CancellationToken cancellationToken)
{
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
var periodDays = Math.Clamp(request.PeriodDays, 7, 365);
var normalizedSegmentCode = CustomerAnalysisSegmentSupport.NormalizeSegmentCode(request.SegmentCode);
var segmentMeta = CustomerAnalysisSegmentSupport.ResolveSegmentMeta(normalizedSegmentCode);
if (request.VisibleStoreIds.Count == 0)
{
return new CustomerAnalysisSegmentListResultDto
{
SegmentCode = segmentMeta.SegmentCode,
SegmentTitle = segmentMeta.SegmentTitle,
SegmentDescription = segmentMeta.SegmentDescription,
Page = page,
PageSize = pageSize,
TotalCount = 0,
Items = []
};
}
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var nowUtc = DateTime.UtcNow;
var segmentCustomers = CustomerAnalysisSegmentSupport.FilterBySegment(
customers,
normalizedSegmentCode,
nowUtc,
periodDays);
var keywordFiltered = CustomerAnalysisSegmentSupport.ApplyKeyword(
segmentCustomers,
request.Keyword);
var sortedCustomers = keywordFiltered
.OrderByDescending(item => item.TotalAmount)
.ThenByDescending(item => item.OrderCount)
.ThenByDescending(item => item.LastOrderAt)
.ThenBy(item => item.CustomerKey, StringComparer.Ordinal)
.ToList();
var pagedItems = sortedCustomers
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(item => new CustomerAnalysisSegmentListItemDto
{
CustomerKey = item.CustomerKey,
Name = item.Name,
PhoneMasked = item.PhoneMasked,
AvatarText = item.AvatarText,
AvatarColor = item.AvatarColor,
Tags = item.Tags,
IsMember = item.Member.IsMember,
MemberTierName = item.Member.TierName,
TotalAmount = item.TotalAmount,
OrderCount = item.OrderCount,
AverageAmount = item.AverageAmount,
RegisteredAt = item.RegisteredAt,
LastOrderAt = item.LastOrderAt,
IsDimmed = item.IsDimmed
})
.ToList();
return new CustomerAnalysisSegmentListResultDto
{
SegmentCode = segmentMeta.SegmentCode,
SegmentTitle = segmentMeta.SegmentTitle,
SegmentDescription = segmentMeta.SegmentDescription,
Items = pagedItems,
Page = page,
PageSize = pageSize,
TotalCount = sortedCustomers.Count
};
}
}

View File

@@ -0,0 +1,77 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
using TakeoutSaaS.Application.App.Customers.Queries;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Customers.Handlers;
/// <summary>
/// 会员详情查询处理器。
/// </summary>
public sealed class GetCustomerMemberDetailQueryHandler(
IOrderRepository orderRepository,
IDapperExecutor dapperExecutor,
ITenantProvider tenantProvider)
: IRequestHandler<GetCustomerMemberDetailQuery, CustomerMemberDetailDto?>
{
/// <inheritdoc />
public async Task<CustomerMemberDetailDto?> Handle(
GetCustomerMemberDetailQuery request,
CancellationToken cancellationToken)
{
var customerKey = CustomerAnalyticsSupport.NormalizePhone(request.CustomerKey);
if (request.VisibleStoreIds.Count == 0 || string.IsNullOrWhiteSpace(customerKey))
{
return null;
}
var customers = await CustomerAnalyticsSupport.LoadCustomersAsync(
orderRepository,
dapperExecutor,
tenantProvider,
request.VisibleStoreIds,
cancellationToken);
var customer = customers.FirstOrDefault(item =>
string.Equals(item.CustomerKey, customerKey, StringComparison.Ordinal));
if (customer is null)
{
return null;
}
var tenantId = tenantProvider.GetCurrentTenantId();
if (tenantId <= 0)
{
return null;
}
var orderIds = customer.Orders
.Select(item => item.OrderId)
.ToList();
var itemLookup = await orderRepository.GetItemsByOrderIdsAsync(orderIds, tenantId, cancellationToken);
var recentOrders = CustomerAnalyticsSupport.BuildRecentOrders(customer.Orders, itemLookup, 5);
var repurchaseRatePercent = CustomerAnalyticsSupport.ToRatePercent(
Math.Max(0, customer.OrderCount - 1),
customer.OrderCount);
return new CustomerMemberDetailDto
{
CustomerKey = customer.CustomerKey,
Name = customer.Name,
PhoneMasked = customer.PhoneMasked,
Source = customer.Source,
RegisteredAt = customer.RegisteredAt,
LastOrderAt = customer.LastOrderAt,
Member = customer.Member,
Tags = customer.Tags,
TotalOrders = customer.OrderCount,
TotalAmount = customer.TotalAmount,
AverageAmount = customer.AverageAmount,
RepurchaseRatePercent = repurchaseRatePercent,
RecentOrders = recentOrders
};
}
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 客户分析报表导出查询。
/// </summary>
public sealed class ExportCustomerAnalysisCsvQuery : IRequest<CustomerExportDto>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 统计周期编码7d/30d/90d/365d
/// </summary>
public string PeriodCode { get; init; } = "30d";
/// <summary>
/// 统计周期天数。
/// </summary>
public int PeriodDays { get; init; } = 30;
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 客户分析总览查询。
/// </summary>
public sealed class GetCustomerAnalysisOverviewQuery : IRequest<CustomerAnalysisOverviewDto>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 统计周期编码7d/30d/90d/365d
/// </summary>
public string PeriodCode { get; init; } = "30d";
/// <summary>
/// 统计周期天数。
/// </summary>
public int PeriodDays { get; init; } = 30;
}

View File

@@ -0,0 +1,45 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 客群明细查询。
/// </summary>
public sealed class GetCustomerAnalysisSegmentListQuery : IRequest<CustomerAnalysisSegmentListResultDto>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 统计周期编码7d/30d/90d/365d
/// </summary>
public string PeriodCode { get; init; } = "30d";
/// <summary>
/// 统计周期天数。
/// </summary>
public int PeriodDays { get; init; } = 30;
/// <summary>
/// 分群编码。
/// </summary>
public string SegmentCode { get; init; } = "all";
/// <summary>
/// 关键词(姓名/手机号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Customers.Dto;
namespace TakeoutSaaS.Application.App.Customers.Queries;
/// <summary>
/// 会员详情查询。
/// </summary>
public sealed class GetCustomerMemberDetailQuery : IRequest<CustomerMemberDetailDto?>
{
/// <summary>
/// 可见门店 ID 集合。
/// </summary>
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
/// <summary>
/// 客户标识(手机号归一化)。
/// </summary>
public string CustomerKey { get; init; } = string.Empty;
}

View File

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

View File

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

View File

@@ -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; } = [];
}

View File

@@ -0,0 +1,248 @@
using System.Globalization;
using TakeoutSaaS.Application.App.Finance.Cost.Dto;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
namespace TakeoutSaaS.Application.App.Finance.Cost.Handlers;
/// <summary>
/// 成本模块映射与文案转换。
/// </summary>
internal static class FinanceCostMapping
{
/// <summary>
/// 维度编码转枚举。
/// </summary>
public static FinanceCostDimension ParseDimensionCode(string? value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"store" => FinanceCostDimension.Store,
_ => FinanceCostDimension.Tenant
};
}
/// <summary>
/// 维度枚举转编码。
/// </summary>
public static string ToDimensionCode(FinanceCostDimension value)
{
return value == FinanceCostDimension.Store ? "store" : "tenant";
}
/// <summary>
/// 分类编码转枚举。
/// </summary>
public static FinanceCostCategory ParseCategoryCode(string? value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
{
"food" => FinanceCostCategory.FoodMaterial,
"labor" => FinanceCostCategory.Labor,
"fixed" => FinanceCostCategory.FixedExpense,
"packaging" => FinanceCostCategory.PackagingConsumable,
_ => FinanceCostCategory.FoodMaterial
};
}
/// <summary>
/// 分类枚举转编码。
/// </summary>
public static string ToCategoryCode(FinanceCostCategory value)
{
return value switch
{
FinanceCostCategory.FoodMaterial => "food",
FinanceCostCategory.Labor => "labor",
FinanceCostCategory.FixedExpense => "fixed",
FinanceCostCategory.PackagingConsumable => "packaging",
_ => "food"
};
}
/// <summary>
/// 分类文案。
/// </summary>
public static string ToCategoryText(FinanceCostCategory value)
{
return value switch
{
FinanceCostCategory.FoodMaterial => "食材原料",
FinanceCostCategory.Labor => "人工成本",
FinanceCostCategory.FixedExpense => "固定费用",
FinanceCostCategory.PackagingConsumable => "包装耗材",
_ => "食材原料"
};
}
/// <summary>
/// 格式化月份字符串yyyy-MM
/// </summary>
public static string ToMonthText(DateTime month)
{
return month.ToString("yyyy-MM", CultureInfo.InvariantCulture);
}
/// <summary>
/// 归一化金额精度。
/// </summary>
public static decimal RoundAmount(decimal value)
{
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
/// <summary>
/// 构建录入页 DTO。
/// </summary>
public static FinanceCostEntryDto ToEntryDto(FinanceCostMonthSnapshot snapshot)
{
// 1. 计算总成本与成本率。
var totalCost = RoundAmount(snapshot.Categories.Sum(item => item.TotalAmount));
var costRate = snapshot.MonthRevenue > 0
? RoundAmount(totalCost / snapshot.MonthRevenue * 100m)
: 0m;
// 2. 映射分类与明细。
var categories = snapshot.Categories.Select(category =>
{
var percentage = totalCost > 0
? RoundAmount(category.TotalAmount / totalCost * 100m)
: 0m;
return new FinanceCostEntryCategoryDto
{
Category = ToCategoryCode(category.Category),
CategoryText = ToCategoryText(category.Category),
TotalAmount = RoundAmount(category.TotalAmount),
Percentage = percentage,
Items = category.Items
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.ItemName)
.Select(item => new FinanceCostEntryDetailDto
{
ItemId = item.ItemId?.ToString(CultureInfo.InvariantCulture),
ItemName = item.ItemName,
Amount = RoundAmount(item.Amount),
Quantity = item.Quantity.HasValue ? RoundAmount(item.Quantity.Value) : null,
UnitPrice = item.UnitPrice.HasValue ? RoundAmount(item.UnitPrice.Value) : null,
SortOrder = item.SortOrder
})
.ToList()
};
}).ToList();
return new FinanceCostEntryDto
{
Dimension = ToDimensionCode(snapshot.Dimension),
StoreId = snapshot.StoreId?.ToString(CultureInfo.InvariantCulture),
Month = ToMonthText(snapshot.CostMonth),
MonthRevenue = RoundAmount(snapshot.MonthRevenue),
TotalCost = totalCost,
CostRate = costRate,
Categories = categories
};
}
/// <summary>
/// 构建分析页 DTO。
/// </summary>
public static FinanceCostAnalysisDto ToAnalysisDto(FinanceCostAnalysisSnapshot snapshot)
{
// 1. 计算统计指标。
var averageCostPerPaidOrder = snapshot.CurrentPaidOrderCount > 0
? RoundAmount(snapshot.CurrentTotalCost / snapshot.CurrentPaidOrderCount)
: 0m;
var foodCostRate = snapshot.CurrentRevenue > 0
? RoundAmount(snapshot.CurrentFoodAmount / snapshot.CurrentRevenue * 100m)
: 0m;
// 2. 映射趋势与明细表。
var trend = snapshot.Trends
.OrderBy(item => item.MonthStartUtc)
.Select(item =>
{
var costRate = item.Revenue > 0
? RoundAmount(item.TotalCost / item.Revenue * 100m)
: 0m;
return new FinanceCostTrendPointDto
{
Month = ToMonthText(item.MonthStartUtc),
TotalCost = RoundAmount(item.TotalCost),
Revenue = RoundAmount(item.Revenue),
CostRate = costRate
};
})
.ToList();
var detailRows = snapshot.DetailRows
.OrderByDescending(item => item.MonthStartUtc)
.Select(item =>
{
var costRate = item.Revenue > 0
? RoundAmount(item.TotalCost / item.Revenue * 100m)
: 0m;
return new FinanceCostMonthlyDetailRowDto
{
Month = ToMonthText(item.MonthStartUtc),
FoodAmount = RoundAmount(item.FoodAmount),
LaborAmount = RoundAmount(item.LaborAmount),
FixedAmount = RoundAmount(item.FixedAmount),
PackagingAmount = RoundAmount(item.PackagingAmount),
TotalCost = RoundAmount(item.TotalCost),
CostRate = costRate
};
})
.ToList();
// 3. 构建成本构成。
var totalCost = RoundAmount(snapshot.CurrentTotalCost);
var composition = snapshot.CurrentCategories
.OrderBy(item => item.Category)
.Select(item => new FinanceCostCompositionItemDto
{
Category = ToCategoryCode(item.Category),
CategoryText = ToCategoryText(item.Category),
Amount = RoundAmount(item.TotalAmount),
Percentage = totalCost > 0
? RoundAmount(item.TotalAmount / totalCost * 100m)
: 0m
})
.ToList();
return new FinanceCostAnalysisDto
{
Dimension = ToDimensionCode(snapshot.Dimension),
StoreId = snapshot.StoreId?.ToString(CultureInfo.InvariantCulture),
Month = ToMonthText(snapshot.CostMonth),
Stats = new FinanceCostAnalysisStatsDto
{
TotalCost = totalCost,
FoodCostRate = foodCostRate,
AverageCostPerPaidOrder = averageCostPerPaidOrder,
MonthOnMonthChangeRate = RoundAmount(snapshot.MonthOnMonthChangeRate),
Revenue = RoundAmount(snapshot.CurrentRevenue),
PaidOrderCount = snapshot.CurrentPaidOrderCount
},
Trend = trend,
Composition = composition,
DetailRows = detailRows
};
}
/// <summary>
/// 归一化为月份起始 UTC 时间。
/// </summary>
public static DateTime NormalizeMonthStart(DateTime value)
{
var utcValue = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
return new DateTime(utcValue.Year, utcValue.Month, 1, 0, 0, 0, DateTimeKind.Utc);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, "生成发票号码失败,请稍后重试");
}
}

View File

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

View File

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

View File

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

View File

@@ -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, "电子专用发票未启用");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
{
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
/// 经营报表批量导出处理器ZIPPDF + 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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
/// 批量导出经营报表ZIPPDF + 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More