Compare commits

..

7 Commits

Author SHA1 Message Date
8d170ba3f9 feat(finance): 完成发票管理模块后端实现 2026-03-04 16:54:30 +08:00
fa7b006373 Merge pull request 'feat(finance): add cost management backend module' (#6) from feature/finance-cost-1to1 into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 2m15s
Reviewed-on: #6
2026-03-04 08:16:34 +00:00
c8359c5fc3 Merge pull request 'feature/finance-report-1to1' (#5) from feature/finance-report-1to1 into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 2m0s
Reviewed-on: #5
2026-03-04 08:12:46 +00:00
59ebe70ed3 Merge remote-tracking branch 'gitea/dev' into feature/finance-report-1to1 2026-03-04 16:09:50 +08:00
1efa392f36 Merge pull request 'feat(member): implement points mall backend module' (#3) from feature/member-points-mall-1to1 into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m53s
Reviewed-on: #3
2026-03-04 04:33:26 +00:00
b57b3ab228 Merge pull request 'feat: 完成会员消息触达后端模块' (#2) from feature/member-message-reach-module into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m55s
Reviewed-on: #2
2026-03-04 04:18:14 +00:00
a88ca4056c Merge pull request 'feat: 新增财务交易流水后端模块' (#1) from feature/finance-transaction-module into dev
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m16s
Reviewed-on: #1
2026-03-04 03:43:59 +00:00
62 changed files with 134 additions and 17209 deletions

View File

@@ -1,285 +0,0 @@
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

@@ -1,329 +0,0 @@
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

@@ -79,10 +79,6 @@ public sealed class StoreFeesSettingsDto
/// </summary>
public decimal BaseDeliveryFee { get; set; }
/// <summary>
/// PlatformServiceRate。
/// </summary>
public decimal PlatformServiceRate { get; set; }
/// <summary>
/// FreeDeliveryThreshold。
/// </summary>
public decimal? FreeDeliveryThreshold { get; set; }

View File

@@ -1,171 +0,0 @@
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

@@ -1,250 +0,0 @@
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

@@ -64,7 +64,6 @@ public sealed class StoreFeesController(
StoreId = parsedStoreId,
MinimumOrderAmount = request.MinimumOrderAmount,
DeliveryFee = request.BaseDeliveryFee,
PlatformServiceRate = request.PlatformServiceRate,
FreeDeliveryThreshold = request.FreeDeliveryThreshold,
PackagingFeeMode = ParsePackagingFeeMode(request.PackagingFeeMode),
OrderPackagingFeeMode = ParseOrderPackagingFeeMode(request.OrderPackagingFeeMode),
@@ -176,7 +175,6 @@ public sealed class StoreFeesController(
targetFee.MinimumOrderAmount = sourceFee.MinimumOrderAmount;
targetFee.BaseDeliveryFee = sourceFee.BaseDeliveryFee;
targetFee.PlatformServiceRate = sourceFee.PlatformServiceRate;
targetFee.FreeDeliveryThreshold = sourceFee.FreeDeliveryThreshold;
targetFee.PackagingFeeMode = sourceFee.PackagingFeeMode;
targetFee.OrderPackagingFeeMode = sourceFee.OrderPackagingFeeMode;
@@ -216,7 +214,6 @@ public sealed class StoreFeesController(
IsConfigured = source is not null,
MinimumOrderAmount = source?.MinimumOrderAmount ?? 0m,
BaseDeliveryFee = source?.DeliveryFee ?? 0m,
PlatformServiceRate = source?.PlatformServiceRate ?? 0m,
FreeDeliveryThreshold = source?.FreeDeliveryThreshold,
PackagingFeeMode = ToPackagingFeeModeText(source?.PackagingFeeMode ?? PackagingFeeMode.Fixed),
OrderPackagingFeeMode = ToOrderPackagingFeeModeText(source?.OrderPackagingFeeMode ?? OrderPackagingFeeMode.Fixed),

View File

@@ -1,313 +0,0 @@
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

@@ -1,245 +0,0 @@
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

@@ -1,34 +0,0 @@
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

@@ -1,26 +0,0 @@
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

@@ -1,31 +0,0 @@
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

@@ -1,218 +0,0 @@
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

@@ -1,112 +0,0 @@
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

@@ -1,59 +0,0 @@
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

@@ -1,59 +0,0 @@
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

@@ -1,178 +0,0 @@
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

@@ -1,39 +0,0 @@
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

@@ -1,57 +0,0 @@
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

@@ -1,31 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,31 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -24,11 +24,6 @@ public sealed record UpdateStoreFeeCommand : IRequest<StoreFeeDto>
/// </summary>
public decimal DeliveryFee { get; init; }
/// <summary>
/// 平台服务费率(%)。
/// </summary>
public decimal PlatformServiceRate { get; init; }
/// <summary>
/// 打包费模式。
/// </summary>

View File

@@ -31,11 +31,6 @@ public sealed record StoreFeeDto
/// </summary>
public decimal DeliveryFee { get; init; }
/// <summary>
/// 平台服务费率(%)。
/// </summary>
public decimal PlatformServiceRate { get; init; }
/// <summary>
/// 餐具费是否启用。
/// </summary>

View File

@@ -53,7 +53,6 @@ public sealed class UpdateStoreFeeCommandHandler(
// 3. (空行后) 应用更新字段
fee.MinimumOrderAmount = request.MinimumOrderAmount;
fee.BaseDeliveryFee = request.DeliveryFee;
fee.PlatformServiceRate = request.PlatformServiceRate;
fee.PackagingFeeMode = request.PackagingFeeMode;
fee.OrderPackagingFeeMode = request.PackagingFeeMode == PackagingFeeMode.Fixed
? request.OrderPackagingFeeMode

View File

@@ -67,7 +67,6 @@ public static class StoreMapping
StoreId = fee.StoreId,
MinimumOrderAmount = fee.MinimumOrderAmount,
DeliveryFee = fee.BaseDeliveryFee,
PlatformServiceRate = fee.PlatformServiceRate,
CutleryFeeEnabled = fee.CutleryFeeEnabled,
CutleryFeeAmount = fee.CutleryFeeAmount,
RushFeeEnabled = fee.RushFeeEnabled,

View File

@@ -17,7 +17,6 @@ public sealed class UpdateStoreFeeCommandValidator : AbstractValidator<UpdateSto
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).LessThanOrEqualTo(9999.99m);
RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).LessThanOrEqualTo(999.99m);
RuleFor(x => x.PlatformServiceRate).GreaterThanOrEqualTo(0).LessThanOrEqualTo(100m);
RuleFor(x => x.FreeDeliveryThreshold).GreaterThanOrEqualTo(0).When(x => x.FreeDeliveryThreshold.HasValue);
RuleFor(x => x.FixedPackagingFee)

View File

@@ -1,111 +0,0 @@
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Finance.Entities;
/// <summary>
/// 经营报表快照实体。
/// </summary>
public sealed class FinanceBusinessReportSnapshot : MultiTenantEntityBase
{
/// <summary>
/// 所属门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 周期类型。
/// </summary>
public FinanceBusinessReportPeriodType PeriodType { get; set; }
/// <summary>
/// 周期开始时间UTC
/// </summary>
public DateTime PeriodStartAt { get; set; }
/// <summary>
/// 周期结束时间UTC不含
/// </summary>
public DateTime PeriodEndAt { get; set; }
/// <summary>
/// 生成状态。
/// </summary>
public FinanceBusinessReportStatus Status { get; set; } = FinanceBusinessReportStatus.Queued;
/// <summary>
/// 营业额。
/// </summary>
public decimal RevenueAmount { get; set; }
/// <summary>
/// 订单数。
/// </summary>
public int OrderCount { get; set; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageOrderValue { get; set; }
/// <summary>
/// 退款率0-1
/// </summary>
public decimal RefundRate { get; set; }
/// <summary>
/// 成本总额。
/// </summary>
public decimal CostTotalAmount { get; set; }
/// <summary>
/// 净利润。
/// </summary>
public decimal NetProfitAmount { get; set; }
/// <summary>
/// 利润率0-1
/// </summary>
public decimal ProfitRate { get; set; }
/// <summary>
/// KPI 比较快照 JSON同比/环比)。
/// </summary>
public string KpiComparisonJson { get; set; } = "[]";
/// <summary>
/// 收入明细快照 JSON按渠道
/// </summary>
public string IncomeBreakdownJson { get; set; } = "[]";
/// <summary>
/// 成本明细快照 JSON按类别
/// </summary>
public string CostBreakdownJson { get; set; } = "[]";
/// <summary>
/// 生成开始时间UTC
/// </summary>
public DateTime? StartedAt { get; set; }
/// <summary>
/// 生成完成时间UTC
/// </summary>
public DateTime? FinishedAt { get; set; }
/// <summary>
/// 最近一次失败信息。
/// </summary>
public string? LastError { get; set; }
/// <summary>
/// 重试次数。
/// </summary>
public int RetryCount { get; set; }
/// <summary>
/// 调度任务 ID。
/// </summary>
public string? HangfireJobId { get; set; }
}

View File

@@ -1,36 +0,0 @@
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Finance.Entities;
/// <summary>
/// 成本日覆盖实体。
/// </summary>
public sealed class FinanceCostDailyOverride : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 业务日期UTC 日期)。
/// </summary>
public DateTime BusinessDate { get; set; }
/// <summary>
/// 成本分类。
/// </summary>
public FinanceCostCategory Category { get; set; }
/// <summary>
/// 覆盖金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; set; }
}

View File

@@ -1,56 +0,0 @@
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Finance.Entities;
/// <summary>
/// 成本配置实体(类别级规则)。
/// </summary>
public sealed class FinanceCostProfile : MultiTenantEntityBase
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 成本分类。
/// </summary>
public FinanceCostCategory Category { get; set; }
/// <summary>
/// 计算模式。
/// </summary>
public FinanceCostCalcMode CalcMode { get; set; }
/// <summary>
/// 比例值0-1Ratio 模式使用)。
/// </summary>
public decimal Ratio { get; set; }
/// <summary>
/// 固定日金额FixedDaily 模式使用)。
/// </summary>
public decimal FixedDailyAmount { get; set; }
/// <summary>
/// 生效开始日期UTC 日期)。
/// </summary>
public DateTime EffectiveFrom { get; set; }
/// <summary>
/// 生效结束日期UTC 日期null 表示长期)。
/// </summary>
public DateTime? EffectiveTo { get; set; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; set; } = 100;
}

View File

@@ -1,23 +0,0 @@
namespace TakeoutSaaS.Domain.Finance.Enums;
/// <summary>
/// 经营报表周期类型。
/// </summary>
public enum FinanceBusinessReportPeriodType
{
/// <summary>
/// 日报。
/// </summary>
Daily = 1,
/// <summary>
/// 周报。
/// </summary>
Weekly = 2,
/// <summary>
/// 月报。
/// </summary>
Monthly = 3
}

View File

@@ -1,28 +0,0 @@
namespace TakeoutSaaS.Domain.Finance.Enums;
/// <summary>
/// 经营报表快照状态。
/// </summary>
public enum FinanceBusinessReportStatus
{
/// <summary>
/// 已排队。
/// </summary>
Queued = 1,
/// <summary>
/// 生成中。
/// </summary>
Running = 2,
/// <summary>
/// 已生成。
/// </summary>
Succeeded = 3,
/// <summary>
/// 生成失败。
/// </summary>
Failed = 4
}

View File

@@ -1,18 +0,0 @@
namespace TakeoutSaaS.Domain.Finance.Enums;
/// <summary>
/// 成本计算模式。
/// </summary>
public enum FinanceCostCalcMode
{
/// <summary>
/// 按营业额比例计算。
/// </summary>
Ratio = 1,
/// <summary>
/// 按固定日金额计算。
/// </summary>
FixedDaily = 2
}

View File

@@ -1,245 +0,0 @@
using TakeoutSaaS.Domain.Finance.Enums;
namespace TakeoutSaaS.Domain.Finance.Models;
/// <summary>
/// 经营报表 KPI 快照项。
/// </summary>
public sealed class FinanceBusinessReportKpiSnapshot
{
/// <summary>
/// 指标键。
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// 指标名称。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 指标值。
/// </summary>
public decimal Value { get; set; }
/// <summary>
/// 同比变化率(百分数,如 3.5 表示 +3.5%)。
/// </summary>
public decimal YoyChangeRate { get; set; }
/// <summary>
/// 环比变化率(百分数,如 2.1 表示 +2.1%)。
/// </summary>
public decimal MomChangeRate { get; set; }
}
/// <summary>
/// 经营报表明细行快照。
/// </summary>
public sealed class FinanceBusinessReportBreakdownSnapshot
{
/// <summary>
/// 明细键。
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// 明细名称。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 占比0-1
/// </summary>
public decimal Ratio { get; set; }
}
/// <summary>
/// 经营报表列表行快照。
/// </summary>
public sealed class FinanceBusinessReportListItemSnapshot
{
/// <summary>
/// 报表 ID。
/// </summary>
public long ReportId { get; set; }
/// <summary>
/// 周期类型。
/// </summary>
public FinanceBusinessReportPeriodType PeriodType { get; set; }
/// <summary>
/// 周期开始时间UTC
/// </summary>
public DateTime PeriodStartAt { get; set; }
/// <summary>
/// 周期结束时间UTC不含
/// </summary>
public DateTime PeriodEndAt { get; set; }
/// <summary>
/// 状态。
/// </summary>
public FinanceBusinessReportStatus Status { get; set; }
/// <summary>
/// 营业额。
/// </summary>
public decimal RevenueAmount { get; set; }
/// <summary>
/// 订单数。
/// </summary>
public int OrderCount { get; set; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageOrderValue { get; set; }
/// <summary>
/// 退款率0-1
/// </summary>
public decimal RefundRate { get; set; }
/// <summary>
/// 成本总额。
/// </summary>
public decimal CostTotalAmount { get; set; }
/// <summary>
/// 净利润。
/// </summary>
public decimal NetProfitAmount { get; set; }
/// <summary>
/// 利润率0-1
/// </summary>
public decimal ProfitRate { get; set; }
}
/// <summary>
/// 经营报表分页快照。
/// </summary>
public sealed class FinanceBusinessReportPageSnapshot
{
/// <summary>
/// 列表。
/// </summary>
public List<FinanceBusinessReportListItemSnapshot> Items { get; set; } = [];
/// <summary>
/// 总数。
/// </summary>
public int TotalCount { get; set; }
}
/// <summary>
/// 经营报表详情快照。
/// </summary>
public sealed class FinanceBusinessReportDetailSnapshot
{
/// <summary>
/// 报表 ID。
/// </summary>
public long ReportId { get; set; }
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; set; }
/// <summary>
/// 周期类型。
/// </summary>
public FinanceBusinessReportPeriodType PeriodType { get; set; }
/// <summary>
/// 周期开始时间UTC
/// </summary>
public DateTime PeriodStartAt { get; set; }
/// <summary>
/// 周期结束时间UTC不含
/// </summary>
public DateTime PeriodEndAt { get; set; }
/// <summary>
/// 状态。
/// </summary>
public FinanceBusinessReportStatus Status { get; set; }
/// <summary>
/// 营业额。
/// </summary>
public decimal RevenueAmount { get; set; }
/// <summary>
/// 订单数。
/// </summary>
public int OrderCount { get; set; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageOrderValue { get; set; }
/// <summary>
/// 退款率0-1
/// </summary>
public decimal RefundRate { get; set; }
/// <summary>
/// 成本总额。
/// </summary>
public decimal CostTotalAmount { get; set; }
/// <summary>
/// 净利润。
/// </summary>
public decimal NetProfitAmount { get; set; }
/// <summary>
/// 利润率0-1
/// </summary>
public decimal ProfitRate { get; set; }
/// <summary>
/// 关键指标快照列表。
/// </summary>
public List<FinanceBusinessReportKpiSnapshot> Kpis { get; set; } = [];
/// <summary>
/// 收入明细(按渠道)。
/// </summary>
public List<FinanceBusinessReportBreakdownSnapshot> IncomeBreakdowns { get; set; } = [];
/// <summary>
/// 成本明细(按类别)。
/// </summary>
public List<FinanceBusinessReportBreakdownSnapshot> CostBreakdowns { get; set; } = [];
}
/// <summary>
/// 待处理报表任务快照。
/// </summary>
public sealed class FinanceBusinessReportPendingSnapshot
{
/// <summary>
/// 快照 ID。
/// </summary>
public long SnapshotId { get; set; }
/// <summary>
/// 租户 ID。
/// </summary>
public long TenantId { get; set; }
}

View File

@@ -1,211 +0,0 @@
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Orders.Enums;
namespace TakeoutSaaS.Domain.Finance.Models;
/// <summary>
/// 财务概览核心指标快照。
/// </summary>
public sealed record FinanceOverviewSummarySnapshot
{
/// <summary>
/// 今日营业额(支付成功总额)。
/// </summary>
public decimal TodayGrossRevenue { get; init; }
/// <summary>
/// 昨日营业额(支付成功总额)。
/// </summary>
public decimal YesterdayGrossRevenue { get; init; }
/// <summary>
/// 今日实收(营业额 - 退款)。
/// </summary>
public decimal TodayNetReceived { get; init; }
/// <summary>
/// 昨日实收(营业额 - 退款)。
/// </summary>
public decimal YesterdayNetReceived { get; init; }
/// <summary>
/// 今日退款。
/// </summary>
public decimal TodayRefundAmount { get; init; }
/// <summary>
/// 昨日退款。
/// </summary>
public decimal YesterdayRefundAmount { get; init; }
/// <summary>
/// 今日总成本。
/// </summary>
public decimal TodayTotalCost { get; init; }
/// <summary>
/// 昨日总成本。
/// </summary>
public decimal YesterdayTotalCost { get; init; }
/// <summary>
/// 当前可提现余额(累计净收入口径)。
/// </summary>
public decimal WithdrawableBalance { get; init; }
/// <summary>
/// 上周同日可提现余额(用于环比)。
/// </summary>
public decimal WithdrawableBalanceLastWeek { get; init; }
}
/// <summary>
/// 收入趋势点快照。
/// </summary>
public sealed record FinanceOverviewIncomeTrendPointSnapshot
{
/// <summary>
/// 业务日期UTC 日期)。
/// </summary>
public required DateTime BusinessDate { get; init; }
/// <summary>
/// 实收金额。
/// </summary>
public decimal NetReceivedAmount { get; init; }
}
/// <summary>
/// 利润趋势点快照。
/// </summary>
public sealed record FinanceOverviewProfitTrendPointSnapshot
{
/// <summary>
/// 业务日期UTC 日期)。
/// </summary>
public required DateTime BusinessDate { get; init; }
/// <summary>
/// 营收金额(支付成功总额)。
/// </summary>
public decimal RevenueAmount { get; init; }
/// <summary>
/// 成本金额。
/// </summary>
public decimal CostAmount { get; init; }
/// <summary>
/// 净利润金额。
/// </summary>
public decimal NetProfitAmount { get; init; }
}
/// <summary>
/// 收入构成项快照。
/// </summary>
public sealed record FinanceOverviewIncomeCompositionSnapshot
{
/// <summary>
/// 渠道。
/// </summary>
public required DeliveryType Channel { get; init; }
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; init; }
}
/// <summary>
/// 成本构成项快照。
/// </summary>
public sealed record FinanceOverviewCostCompositionSnapshot
{
/// <summary>
/// 分类编码food/labor/fixed/packaging/platform
/// </summary>
public required string CategoryCode { get; init; }
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; init; }
}
/// <summary>
/// TOP 商品快照。
/// </summary>
public sealed record FinanceOverviewTopProductSnapshot
{
/// <summary>
/// 商品标识。
/// </summary>
public long ProductId { get; init; }
/// <summary>
/// 商品名称。
/// </summary>
public required string ProductName { get; init; }
/// <summary>
/// 销量。
/// </summary>
public int SalesQuantity { get; init; }
/// <summary>
/// 营收金额。
/// </summary>
public decimal RevenueAmount { get; init; }
}
/// <summary>
/// 财务概览页面快照。
/// </summary>
public sealed record FinanceOverviewDashboardSnapshot
{
/// <summary>
/// 统计维度。
/// </summary>
public required FinanceCostDimension Dimension { get; init; }
/// <summary>
/// 门店标识(租户维度为空)。
/// </summary>
public long? StoreId { get; init; }
/// <summary>
/// 核心指标汇总。
/// </summary>
public required FinanceOverviewSummarySnapshot Summary { get; init; }
/// <summary>
/// 近 30 天收入趋势。
/// </summary>
public IReadOnlyList<FinanceOverviewIncomeTrendPointSnapshot> IncomeTrend { get; init; } = [];
/// <summary>
/// 近 30 天利润趋势。
/// </summary>
public IReadOnlyList<FinanceOverviewProfitTrendPointSnapshot> ProfitTrend { get; init; } = [];
/// <summary>
/// 收入构成。
/// </summary>
public IReadOnlyList<FinanceOverviewIncomeCompositionSnapshot> IncomeComposition { get; init; } = [];
/// <summary>
/// 成本构成。
/// </summary>
public IReadOnlyList<FinanceOverviewCostCompositionSnapshot> CostComposition { get; init; } = [];
/// <summary>
/// TOP10 商品营收排行。
/// </summary>
public IReadOnlyList<FinanceOverviewTopProductSnapshot> TopProducts { get; init; } = [];
/// <summary>
/// TOP 榜单统计周期内商品总营收。
/// </summary>
public decimal TopProductTotalRevenue { get; init; }
}

View File

@@ -1,77 +0,0 @@
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
namespace TakeoutSaaS.Domain.Finance.Repositories;
/// <summary>
/// 经营报表仓储契约。
/// </summary>
public interface IFinanceBusinessReportRepository
{
/// <summary>
/// 确保门店存在默认成本配置。
/// </summary>
Task EnsureDefaultCostProfilesAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default);
/// <summary>
/// 为指定分页周期补齐快照并排队。
/// </summary>
Task QueueSnapshotsForPageAsync(
long tenantId,
long storeId,
FinanceBusinessReportPeriodType periodType,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询经营报表分页结果。
/// </summary>
Task<FinanceBusinessReportPageSnapshot> SearchPageAsync(
long tenantId,
long storeId,
FinanceBusinessReportPeriodType periodType,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询经营报表详情。
/// </summary>
Task<FinanceBusinessReportDetailSnapshot?> GetDetailAsync(
long tenantId,
long storeId,
long reportId,
bool allowRealtimeBuild,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询批量导出详情集合。
/// </summary>
Task<IReadOnlyList<FinanceBusinessReportDetailSnapshot>> ListBatchDetailsAsync(
long tenantId,
long storeId,
FinanceBusinessReportPeriodType periodType,
int page,
int pageSize,
bool allowRealtimeBuild,
CancellationToken cancellationToken = default);
/// <summary>
/// 拉取待处理任务。
/// </summary>
Task<IReadOnlyList<FinanceBusinessReportPendingSnapshot>> GetPendingSnapshotsAsync(
int take,
CancellationToken cancellationToken = default);
/// <summary>
/// 执行报表快照生成。
/// </summary>
Task GenerateSnapshotAsync(
long snapshotId,
CancellationToken cancellationToken = default);
}

View File

@@ -1,25 +0,0 @@
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
namespace TakeoutSaaS.Domain.Finance.Repositories;
/// <summary>
/// 财务概览仓储契约。
/// </summary>
public interface IFinanceOverviewRepository
{
/// <summary>
/// 获取财务概览页快照。
/// </summary>
/// <param name="tenantId">租户标识。</param>
/// <param name="dimension">统计维度。</param>
/// <param name="storeId">门店标识(门店维度必填)。</param>
/// <param name="currentUtc">当前 UTC 时间。</param>
/// <param name="cancellationToken">取消标记。</param>
Task<FinanceOverviewDashboardSnapshot> GetDashboardSnapshotAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime currentUtc,
CancellationToken cancellationToken = default);
}

View File

@@ -1,38 +0,0 @@
using TakeoutSaaS.Domain.Finance.Models;
namespace TakeoutSaaS.Domain.Finance.Services;
/// <summary>
/// 经营报表导出服务契约。
/// </summary>
public interface IFinanceBusinessReportExportService
{
/// <summary>
/// 导出单条报表 PDF。
/// </summary>
Task<byte[]> ExportSinglePdfAsync(
FinanceBusinessReportDetailSnapshot detail,
CancellationToken cancellationToken = default);
/// <summary>
/// 导出单条报表 Excel。
/// </summary>
Task<byte[]> ExportSingleExcelAsync(
FinanceBusinessReportDetailSnapshot detail,
CancellationToken cancellationToken = default);
/// <summary>
/// 导出批量报表 PDF。
/// </summary>
Task<byte[]> ExportBatchPdfAsync(
IReadOnlyList<FinanceBusinessReportDetailSnapshot> details,
CancellationToken cancellationToken = default);
/// <summary>
/// 导出批量报表 Excel。
/// </summary>
Task<byte[]> ExportBatchExcelAsync(
IReadOnlyList<FinanceBusinessReportDetailSnapshot> details,
CancellationToken cancellationToken = default);
}

View File

@@ -23,11 +23,6 @@ public sealed class StoreFee : MultiTenantEntityBase
/// </summary>
public decimal BaseDeliveryFee { get; set; } = 0m;
/// <summary>
/// 平台服务费率(%)。
/// </summary>
public decimal PlatformServiceRate { get; set; } = 0m;
/// <summary>
/// 打包费模式。
/// </summary>

View File

@@ -2,7 +2,6 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Domain.Finance.Services;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Domain.Inventory.Repositories;
@@ -57,8 +56,6 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IStoredCardRepository, EfStoredCardRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IFinanceCostRepository, EfFinanceCostRepository>();
services.AddScoped<IFinanceOverviewRepository, EfFinanceOverviewRepository>();
services.AddScoped<IFinanceBusinessReportRepository, EfFinanceBusinessReportRepository>();
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
@@ -83,7 +80,6 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IBillingDomainService, BillingDomainService>();
services.AddScoped<IBillingExportService, BillingExportService>();
services.AddScoped<IMerchantExportService, MerchantExportService>();
services.AddScoped<IFinanceBusinessReportExportService, FinanceBusinessReportExportService>();
// 2. (空行后) 门店配置服务
services.AddScoped<IGeoJsonValidationService, GeoJsonValidationService>();

View File

@@ -103,18 +103,6 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<TenantInvoiceRecord> TenantInvoiceRecords => Set<TenantInvoiceRecord>();
/// <summary>
/// 经营报表快照。
/// </summary>
public DbSet<FinanceBusinessReportSnapshot> FinanceBusinessReportSnapshots => Set<FinanceBusinessReportSnapshot>();
/// <summary>
/// 成本配置。
/// </summary>
public DbSet<FinanceCostProfile> FinanceCostProfiles => Set<FinanceCostProfile>();
/// <summary>
/// 成本日覆盖。
/// </summary>
public DbSet<FinanceCostDailyOverride> FinanceCostDailyOverrides => Set<FinanceCostDailyOverride>();
/// <summary>
/// 成本录入汇总。
/// </summary>
public DbSet<FinanceCostEntry> FinanceCostEntries => Set<FinanceCostEntry>();
@@ -556,9 +544,6 @@ public sealed class TakeoutAppDbContext(
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity<TenantVisibilityRoleRule>());
ConfigureTenantInvoiceSetting(modelBuilder.Entity<TenantInvoiceSetting>());
ConfigureTenantInvoiceRecord(modelBuilder.Entity<TenantInvoiceRecord>());
ConfigureFinanceBusinessReportSnapshot(modelBuilder.Entity<FinanceBusinessReportSnapshot>());
ConfigureFinanceCostProfile(modelBuilder.Entity<FinanceCostProfile>());
ConfigureFinanceCostDailyOverride(modelBuilder.Entity<FinanceCostDailyOverride>());
ConfigureFinanceCostEntry(modelBuilder.Entity<FinanceCostEntry>());
ConfigureFinanceCostEntryItem(modelBuilder.Entity<FinanceCostEntryItem>());
ConfigureQuotaPackage(modelBuilder.Entity<QuotaPackage>());
@@ -810,7 +795,6 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.MinimumOrderAmount).HasPrecision(10, 2);
builder.Property(x => x.BaseDeliveryFee).HasPrecision(10, 2);
builder.Property(x => x.PlatformServiceRate).HasPrecision(5, 2);
builder.Property(x => x.PackagingFeeMode).HasConversion<int>();
builder.Property(x => x.OrderPackagingFeeMode).HasConversion<int>();
builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2);
@@ -1125,68 +1109,6 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.InvoiceType, x.AppliedAt });
}
private static void ConfigureFinanceBusinessReportSnapshot(EntityTypeBuilder<FinanceBusinessReportSnapshot> builder)
{
builder.ToTable("finance_business_report_snapshots");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.PeriodType).HasConversion<int>().IsRequired();
builder.Property(x => x.PeriodStartAt).IsRequired();
builder.Property(x => x.PeriodEndAt).IsRequired();
builder.Property(x => x.Status).HasConversion<int>().IsRequired();
builder.Property(x => x.RevenueAmount).HasPrecision(18, 2);
builder.Property(x => x.OrderCount).IsRequired();
builder.Property(x => x.AverageOrderValue).HasPrecision(18, 2);
builder.Property(x => x.RefundRate).HasPrecision(9, 4);
builder.Property(x => x.CostTotalAmount).HasPrecision(18, 2);
builder.Property(x => x.NetProfitAmount).HasPrecision(18, 2);
builder.Property(x => x.ProfitRate).HasPrecision(9, 4);
builder.Property(x => x.KpiComparisonJson).HasColumnType("text").IsRequired();
builder.Property(x => x.IncomeBreakdownJson).HasColumnType("text").IsRequired();
builder.Property(x => x.CostBreakdownJson).HasColumnType("text").IsRequired();
builder.Property(x => x.LastError).HasMaxLength(1024);
builder.Property(x => x.HangfireJobId).HasMaxLength(64);
builder.Property(x => x.RetryCount).HasDefaultValue(0);
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PeriodType, x.PeriodStartAt }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PeriodType, x.Status, x.PeriodStartAt });
builder.HasIndex(x => new { x.TenantId, x.Status, x.CreatedAt });
}
private static void ConfigureFinanceCostProfile(EntityTypeBuilder<FinanceCostProfile> builder)
{
builder.ToTable("finance_cost_profiles");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.Category).HasConversion<int>().IsRequired();
builder.Property(x => x.CalcMode).HasConversion<int>().IsRequired();
builder.Property(x => x.Ratio).HasPrecision(9, 6).IsRequired();
builder.Property(x => x.FixedDailyAmount).HasPrecision(18, 2).IsRequired();
builder.Property(x => x.EffectiveFrom).IsRequired();
builder.Property(x => x.EffectiveTo);
builder.Property(x => x.IsEnabled).IsRequired();
builder.Property(x => x.SortOrder).HasDefaultValue(100);
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Category, x.EffectiveFrom, x.EffectiveTo });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.IsEnabled, x.SortOrder });
}
private static void ConfigureFinanceCostDailyOverride(EntityTypeBuilder<FinanceCostDailyOverride> builder)
{
builder.ToTable("finance_cost_daily_overrides");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.BusinessDate).IsRequired();
builder.Property(x => x.Category).HasConversion<int>().IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
builder.Property(x => x.Remark).HasMaxLength(256);
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.BusinessDate, x.Category }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.BusinessDate });
}
private static void ConfigureFinanceCostEntry(EntityTypeBuilder<FinanceCostEntry> builder)
{
builder.ToTable("finance_cost_entries");
@@ -2447,3 +2369,4 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt });
}
}

View File

@@ -1,761 +0,0 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Finance.Entities;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Domain.Orders.Enums;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 经营报表 EF Core 仓储实现。
/// </summary>
public sealed class EfFinanceBusinessReportRepository(
TakeoutAppDbContext context,
ITenantContextAccessor tenantContextAccessor) : IFinanceBusinessReportRepository
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly FinanceCostCategory[] CostCategoryOrder =
[
FinanceCostCategory.FoodMaterial,
FinanceCostCategory.Labor,
FinanceCostCategory.FixedExpense,
FinanceCostCategory.PackagingConsumable
];
private static readonly DeliveryType[] IncomeChannelOrder =
[
DeliveryType.Delivery,
DeliveryType.Pickup,
DeliveryType.DineIn
];
private static readonly IReadOnlyDictionary<FinanceCostCategory, (FinanceCostCalcMode Mode, decimal Ratio, decimal Fixed)> DefaultCostProfileMap =
new Dictionary<FinanceCostCategory, (FinanceCostCalcMode, decimal, decimal)>
{
[FinanceCostCategory.FoodMaterial] = (FinanceCostCalcMode.Ratio, 0.36m, 0m),
[FinanceCostCategory.Labor] = (FinanceCostCalcMode.Ratio, 0.19m, 0m),
[FinanceCostCategory.FixedExpense] = (FinanceCostCalcMode.FixedDaily, 0m, 190m),
[FinanceCostCategory.PackagingConsumable] = (FinanceCostCalcMode.Ratio, 0.04m, 0m)
};
/// <inheritdoc />
public async Task EnsureDefaultCostProfilesAsync(long tenantId, long storeId, CancellationToken cancellationToken = default)
{
if (tenantId <= 0 || storeId <= 0)
{
return;
}
var existing = await context.FinanceCostProfiles
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null)
.Select(item => item.Category)
.Distinct()
.ToListAsync(cancellationToken);
var missing = CostCategoryOrder.Where(item => !existing.Contains(item)).ToList();
if (missing.Count == 0)
{
return;
}
var effectiveFrom = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var entities = missing.Select((category, index) =>
{
var profile = DefaultCostProfileMap[category];
return new FinanceCostProfile
{
TenantId = tenantId,
StoreId = storeId,
Category = category,
CalcMode = profile.Mode,
Ratio = profile.Ratio,
FixedDailyAmount = profile.Fixed,
EffectiveFrom = effectiveFrom,
IsEnabled = true,
SortOrder = (index + 1) * 10
};
}).ToList();
await context.FinanceCostProfiles.AddRangeAsync(entities, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task QueueSnapshotsForPageAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, CancellationToken cancellationToken = default)
{
if (tenantId <= 0 || storeId <= 0)
{
return;
}
var now = DateTime.UtcNow;
var periods = BuildPagedPeriods(periodType, page, pageSize, now);
var starts = periods.Select(item => item.StartAt).ToHashSet();
var existing = await context.FinanceBusinessReportSnapshots
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.PeriodType == periodType &&
item.DeletedAt == null &&
starts.Contains(item.PeriodStartAt))
.OrderByDescending(item => item.Id)
.ToListAsync(cancellationToken);
var map = existing.GroupBy(item => item.PeriodStartAt).ToDictionary(group => group.Key, group => group.First());
var changed = false;
foreach (var period in periods)
{
if (!map.TryGetValue(period.StartAt, out var snapshot))
{
await context.FinanceBusinessReportSnapshots.AddAsync(new FinanceBusinessReportSnapshot
{
TenantId = tenantId,
StoreId = storeId,
PeriodType = periodType,
PeriodStartAt = period.StartAt,
PeriodEndAt = period.EndAt,
Status = FinanceBusinessReportStatus.Queued
}, cancellationToken);
changed = true;
continue;
}
if (snapshot.PeriodEndAt != period.EndAt)
{
snapshot.PeriodEndAt = period.EndAt;
changed = true;
}
if (snapshot.Status == FinanceBusinessReportStatus.Failed && snapshot.RetryCount < 5)
{
snapshot.Status = FinanceBusinessReportStatus.Queued;
snapshot.LastError = null;
changed = true;
}
if (now >= period.StartAt
&& now < period.EndAt
&& snapshot.Status == FinanceBusinessReportStatus.Succeeded
&& (!snapshot.FinishedAt.HasValue || snapshot.FinishedAt.Value.AddMinutes(30) <= now))
{
snapshot.Status = FinanceBusinessReportStatus.Queued;
snapshot.StartedAt = null;
snapshot.FinishedAt = null;
snapshot.LastError = null;
changed = true;
}
}
if (changed)
{
await context.SaveChangesAsync(cancellationToken);
}
}
/// <inheritdoc />
public async Task<FinanceBusinessReportPageSnapshot> SearchPageAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
var query = context.FinanceBusinessReportSnapshots
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.PeriodType == periodType &&
item.DeletedAt == null);
var totalCount = await query.CountAsync(cancellationToken);
if (totalCount == 0)
{
return new FinanceBusinessReportPageSnapshot();
}
var items = await query
.OrderByDescending(item => item.PeriodStartAt)
.ThenByDescending(item => item.Id)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.Select(item => new FinanceBusinessReportListItemSnapshot
{
ReportId = item.Id,
PeriodType = item.PeriodType,
PeriodStartAt = item.PeriodStartAt,
PeriodEndAt = item.PeriodEndAt,
Status = item.Status,
RevenueAmount = item.RevenueAmount,
OrderCount = item.OrderCount,
AverageOrderValue = item.AverageOrderValue,
RefundRate = item.RefundRate,
CostTotalAmount = item.CostTotalAmount,
NetProfitAmount = item.NetProfitAmount,
ProfitRate = item.ProfitRate
})
.ToListAsync(cancellationToken);
return new FinanceBusinessReportPageSnapshot
{
Items = items,
TotalCount = totalCount
};
}
/// <inheritdoc />
public async Task<FinanceBusinessReportDetailSnapshot?> GetDetailAsync(long tenantId, long storeId, long reportId, bool allowRealtimeBuild, CancellationToken cancellationToken = default)
{
var snapshot = await context.FinanceBusinessReportSnapshots
.AsNoTracking()
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == reportId && item.DeletedAt == null, cancellationToken);
if (snapshot is null)
{
return null;
}
if (allowRealtimeBuild && snapshot.Status != FinanceBusinessReportStatus.Succeeded)
{
await GenerateSnapshotAsync(reportId, cancellationToken);
snapshot = await context.FinanceBusinessReportSnapshots
.AsNoTracking()
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == reportId && item.DeletedAt == null, cancellationToken);
if (snapshot is null)
{
return null;
}
}
return new FinanceBusinessReportDetailSnapshot
{
ReportId = snapshot.Id,
StoreId = snapshot.StoreId,
PeriodType = snapshot.PeriodType,
PeriodStartAt = snapshot.PeriodStartAt,
PeriodEndAt = snapshot.PeriodEndAt,
Status = snapshot.Status,
RevenueAmount = snapshot.RevenueAmount,
OrderCount = snapshot.OrderCount,
AverageOrderValue = snapshot.AverageOrderValue,
RefundRate = snapshot.RefundRate,
CostTotalAmount = snapshot.CostTotalAmount,
NetProfitAmount = snapshot.NetProfitAmount,
ProfitRate = snapshot.ProfitRate,
Kpis = Deserialize<FinanceBusinessReportKpiSnapshot>(snapshot.KpiComparisonJson),
IncomeBreakdowns = Deserialize<FinanceBusinessReportBreakdownSnapshot>(snapshot.IncomeBreakdownJson),
CostBreakdowns = Deserialize<FinanceBusinessReportBreakdownSnapshot>(snapshot.CostBreakdownJson)
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<FinanceBusinessReportDetailSnapshot>> ListBatchDetailsAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, bool allowRealtimeBuild, CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
var reportIds = await context.FinanceBusinessReportSnapshots
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.PeriodType == periodType && item.DeletedAt == null)
.OrderByDescending(item => item.PeriodStartAt)
.ThenByDescending(item => item.Id)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.Select(item => item.Id)
.ToListAsync(cancellationToken);
var list = new List<FinanceBusinessReportDetailSnapshot>(reportIds.Count);
foreach (var reportId in reportIds)
{
var detail = await GetDetailAsync(tenantId, storeId, reportId, allowRealtimeBuild, cancellationToken);
if (detail is not null)
{
list.Add(detail);
}
}
return list;
}
/// <inheritdoc />
public async Task<IReadOnlyList<FinanceBusinessReportPendingSnapshot>> GetPendingSnapshotsAsync(int take, CancellationToken cancellationToken = default)
{
var normalizedTake = Math.Clamp(take, 1, 200);
if ((tenantContextAccessor.Current?.TenantId ?? 0) != 0)
{
return await QueryPendingAsync(normalizedTake, cancellationToken);
}
var tenantIds = await context.Tenants.AsNoTracking().Where(item => item.DeletedAt == null && item.Id > 0).Select(item => item.Id).ToListAsync(cancellationToken);
var pending = new List<(long SnapshotId, long TenantId, DateTime CreatedAt)>();
foreach (var tenantId in tenantIds)
{
using (tenantContextAccessor.EnterTenantScope(tenantId, "finance-report"))
{
var rows = await context.FinanceBusinessReportSnapshots
.AsNoTracking()
.Where(item =>
item.DeletedAt == null &&
(item.Status == FinanceBusinessReportStatus.Queued || (item.Status == FinanceBusinessReportStatus.Failed && item.RetryCount < 3)))
.OrderBy(item => item.CreatedAt)
.ThenBy(item => item.Id)
.Take(normalizedTake)
.Select(item => new { item.Id, item.TenantId, item.CreatedAt })
.ToListAsync(cancellationToken);
pending.AddRange(rows.Select(item => (item.Id, item.TenantId, item.CreatedAt)));
}
}
return pending.OrderBy(item => item.CreatedAt).ThenBy(item => item.SnapshotId).Take(normalizedTake).Select(item => new FinanceBusinessReportPendingSnapshot { SnapshotId = item.SnapshotId, TenantId = item.TenantId }).ToList();
}
/// <inheritdoc />
public async Task GenerateSnapshotAsync(long snapshotId, CancellationToken cancellationToken = default)
{
var snapshot = await context.FinanceBusinessReportSnapshots.FirstOrDefaultAsync(item => item.Id == snapshotId && item.DeletedAt == null, cancellationToken);
if (snapshot is null)
{
return;
}
if (snapshot.Status == FinanceBusinessReportStatus.Running
&& snapshot.StartedAt.HasValue
&& snapshot.StartedAt.Value.AddMinutes(10) > DateTime.UtcNow)
{
return;
}
snapshot.Status = FinanceBusinessReportStatus.Running;
snapshot.StartedAt = DateTime.UtcNow;
snapshot.LastError = null;
await context.SaveChangesAsync(cancellationToken);
try
{
await EnsureDefaultCostProfilesAsync(snapshot.TenantId, snapshot.StoreId, cancellationToken);
var report = await BuildComputedSnapshotAsync(snapshot.TenantId, snapshot.StoreId, snapshot.PeriodType, snapshot.PeriodStartAt, snapshot.PeriodEndAt, cancellationToken);
snapshot.RevenueAmount = report.RevenueAmount;
snapshot.OrderCount = report.OrderCount;
snapshot.AverageOrderValue = report.AverageOrderValue;
snapshot.RefundRate = report.RefundRate;
snapshot.CostTotalAmount = report.CostTotalAmount;
snapshot.NetProfitAmount = report.NetProfitAmount;
snapshot.ProfitRate = report.ProfitRate;
snapshot.KpiComparisonJson = JsonSerializer.Serialize(report.Kpis, JsonOptions);
snapshot.IncomeBreakdownJson = JsonSerializer.Serialize(report.IncomeBreakdowns, JsonOptions);
snapshot.CostBreakdownJson = JsonSerializer.Serialize(report.CostBreakdowns, JsonOptions);
snapshot.Status = FinanceBusinessReportStatus.Succeeded;
snapshot.FinishedAt = DateTime.UtcNow;
snapshot.LastError = null;
}
catch (Exception ex)
{
snapshot.Status = FinanceBusinessReportStatus.Failed;
snapshot.FinishedAt = DateTime.UtcNow;
snapshot.RetryCount += 1;
snapshot.LastError = ex.Message[..Math.Min(1024, ex.Message.Length)];
}
await context.SaveChangesAsync(cancellationToken);
}
private async Task<IReadOnlyList<FinanceBusinessReportPendingSnapshot>> QueryPendingAsync(int take, CancellationToken cancellationToken)
{
return await context.FinanceBusinessReportSnapshots
.AsNoTracking()
.Where(item =>
item.DeletedAt == null &&
(item.Status == FinanceBusinessReportStatus.Queued
|| (item.Status == FinanceBusinessReportStatus.Failed && item.RetryCount < 3)))
.OrderBy(item => item.CreatedAt)
.ThenBy(item => item.Id)
.Take(take)
.Select(item => new FinanceBusinessReportPendingSnapshot
{
SnapshotId = item.Id,
TenantId = item.TenantId
})
.ToListAsync(cancellationToken);
}
private async Task<ComputedReportSnapshot> BuildComputedSnapshotAsync(
long tenantId,
long storeId,
FinanceBusinessReportPeriodType periodType,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken)
{
var current = await BuildRawMetricsAsync(tenantId, storeId, startAt, endAt, cancellationToken);
var previous = ResolvePreviousPeriod(periodType, startAt, endAt);
var yearAgo = (startAt.AddYears(-1), endAt.AddYears(-1));
var mom = await BuildRawMetricsAsync(tenantId, storeId, previous.StartAt, previous.EndAt, cancellationToken);
var yoy = await BuildRawMetricsAsync(tenantId, storeId, yearAgo.Item1, yearAgo.Item2, cancellationToken);
return current with
{
Kpis = BuildKpis(current, mom, yoy)
};
}
private async Task<ComputedReportSnapshot> BuildRawMetricsAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken)
{
var summary = await QueryRevenueSummaryAsync(tenantId, storeId, startAt, endAt, cancellationToken);
var averageOrderValue = summary.OrderCount <= 0 ? 0m : RoundMoney(summary.RevenueAmount / summary.OrderCount);
var refundRate = summary.OrderCount <= 0 ? 0m : RoundRatio((decimal)summary.RefundOrderCount / summary.OrderCount);
var incomeBreakdowns = await QueryIncomeBreakdownsAsync(tenantId, storeId, startAt, endAt, summary.RevenueAmount, cancellationToken);
var dailyRevenueMap = await QueryDailyRevenueMapAsync(tenantId, storeId, startAt, endAt, cancellationToken);
var costBreakdowns = await BuildCostBreakdownsAsync(tenantId, storeId, startAt, endAt, dailyRevenueMap, cancellationToken);
var costTotalAmount = RoundMoney(costBreakdowns.Sum(item => item.Amount));
var netProfitAmount = RoundMoney(summary.RevenueAmount - costTotalAmount);
var profitRate = summary.RevenueAmount <= 0 ? 0m : RoundRatio(netProfitAmount / summary.RevenueAmount);
return new ComputedReportSnapshot
{
RevenueAmount = summary.RevenueAmount,
OrderCount = summary.OrderCount,
AverageOrderValue = averageOrderValue,
RefundRate = refundRate,
CostTotalAmount = costTotalAmount,
NetProfitAmount = netProfitAmount,
ProfitRate = profitRate,
Kpis = [],
IncomeBreakdowns = incomeBreakdowns,
CostBreakdowns = costBreakdowns
};
}
private async Task<(decimal RevenueAmount, int OrderCount, int RefundOrderCount)> QueryRevenueSummaryAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken)
{
var paidBaseQuery =
from payment in context.PaymentRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on payment.OrderId equals order.Id
where payment.TenantId == tenantId
&& order.TenantId == tenantId
&& order.StoreId == storeId
&& payment.Status == PaymentStatus.Paid
&& (payment.PaidAt ?? payment.CreatedAt) >= startAt
&& (payment.PaidAt ?? payment.CreatedAt) < endAt
select new { payment.Amount, payment.OrderId };
var paidAmount = await paidBaseQuery.Select(item => item.Amount).DefaultIfEmpty(0m).SumAsync(cancellationToken);
var orderCount = await paidBaseQuery.Select(item => item.OrderId).Distinct().CountAsync(cancellationToken);
var refundBaseQuery =
from refund in context.PaymentRefundRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on refund.OrderId equals order.Id
where refund.TenantId == tenantId
&& order.TenantId == tenantId
&& order.StoreId == storeId
&& refund.Status == PaymentRefundStatus.Succeeded
&& (refund.CompletedAt ?? refund.RequestedAt) >= startAt
&& (refund.CompletedAt ?? refund.RequestedAt) < endAt
select new { refund.Amount, refund.OrderId };
var refundAmount = await refundBaseQuery.Select(item => item.Amount).DefaultIfEmpty(0m).SumAsync(cancellationToken);
var refundOrderCount = await refundBaseQuery.Select(item => item.OrderId).Distinct().CountAsync(cancellationToken);
return (RoundMoney(paidAmount - refundAmount), orderCount, refundOrderCount);
}
private async Task<IReadOnlyDictionary<DateTime, decimal>> QueryDailyRevenueMapAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
CancellationToken cancellationToken)
{
var paidRows = await (
from payment in context.PaymentRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on payment.OrderId equals order.Id
where payment.TenantId == tenantId
&& order.TenantId == tenantId
&& order.StoreId == storeId
&& payment.Status == PaymentStatus.Paid
&& (payment.PaidAt ?? payment.CreatedAt) >= startAt
&& (payment.PaidAt ?? payment.CreatedAt) < endAt
group payment by (payment.PaidAt ?? payment.CreatedAt).Date into grouped
select new { BusinessDate = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
.ToListAsync(cancellationToken);
var refundRows = await (
from refund in context.PaymentRefundRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on refund.OrderId equals order.Id
where refund.TenantId == tenantId
&& order.TenantId == tenantId
&& order.StoreId == storeId
&& refund.Status == PaymentRefundStatus.Succeeded
&& (refund.CompletedAt ?? refund.RequestedAt) >= startAt
&& (refund.CompletedAt ?? refund.RequestedAt) < endAt
group refund by (refund.CompletedAt ?? refund.RequestedAt).Date into grouped
select new { BusinessDate = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
.ToListAsync(cancellationToken);
var map = new Dictionary<DateTime, decimal>();
foreach (var row in paidRows)
{
var date = ToUtcDate(row.BusinessDate);
map[date] = map.GetValueOrDefault(date, 0m) + row.Amount;
}
foreach (var row in refundRows)
{
var date = ToUtcDate(row.BusinessDate);
map[date] = map.GetValueOrDefault(date, 0m) - row.Amount;
}
return map.ToDictionary(item => item.Key, item => RoundMoney(item.Value));
}
private async Task<List<FinanceBusinessReportBreakdownSnapshot>> QueryIncomeBreakdownsAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
decimal totalRevenue,
CancellationToken cancellationToken)
{
var paidRows = await (
from payment in context.PaymentRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on payment.OrderId equals order.Id
where payment.TenantId == tenantId
&& order.TenantId == tenantId
&& order.StoreId == storeId
&& payment.Status == PaymentStatus.Paid
&& (payment.PaidAt ?? payment.CreatedAt) >= startAt
&& (payment.PaidAt ?? payment.CreatedAt) < endAt
group payment by order.DeliveryType into grouped
select new { DeliveryType = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
.ToListAsync(cancellationToken);
var refundRows = await (
from refund in context.PaymentRefundRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on refund.OrderId equals order.Id
where refund.TenantId == tenantId
&& order.TenantId == tenantId
&& order.StoreId == storeId
&& refund.Status == PaymentRefundStatus.Succeeded
&& (refund.CompletedAt ?? refund.RequestedAt) >= startAt
&& (refund.CompletedAt ?? refund.RequestedAt) < endAt
group refund by order.DeliveryType into grouped
select new { DeliveryType = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
.ToListAsync(cancellationToken);
var paidMap = paidRows.ToDictionary(item => item.DeliveryType, item => item.Amount);
var refundMap = refundRows.ToDictionary(item => item.DeliveryType, item => item.Amount);
return IncomeChannelOrder.Select(channel =>
{
var amount = paidMap.GetValueOrDefault(channel, 0m) - refundMap.GetValueOrDefault(channel, 0m);
return new FinanceBusinessReportBreakdownSnapshot
{
Key = channel switch
{
DeliveryType.Delivery => "delivery",
DeliveryType.Pickup => "pickup",
DeliveryType.DineIn => "dine_in",
_ => "delivery"
},
Label = channel switch
{
DeliveryType.Delivery => "外卖",
DeliveryType.Pickup => "自提",
DeliveryType.DineIn => "堂食",
_ => "外卖"
},
Amount = RoundMoney(amount),
Ratio = totalRevenue <= 0 ? 0m : RoundRatio(amount / totalRevenue)
};
}).ToList();
}
private async Task<List<FinanceBusinessReportBreakdownSnapshot>> BuildCostBreakdownsAsync(
long tenantId,
long storeId,
DateTime startAt,
DateTime endAt,
IReadOnlyDictionary<DateTime, decimal> dailyRevenueMap,
CancellationToken cancellationToken)
{
var profiles = await context.FinanceCostProfiles.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null && item.IsEnabled)
.OrderBy(item => item.SortOrder).ThenByDescending(item => item.EffectiveFrom).ToListAsync(cancellationToken);
var overrides = await context.FinanceCostDailyOverrides.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null && item.BusinessDate >= startAt.Date && item.BusinessDate < endAt.Date)
.ToListAsync(cancellationToken);
var overrideMap = overrides.ToDictionary(item => $"{item.BusinessDate:yyyyMMdd}:{(int)item.Category}", item => item.Amount);
var categoryAmountMap = CostCategoryOrder.ToDictionary(item => item, _ => 0m);
for (var businessDay = startAt.Date; businessDay < endAt.Date; businessDay = businessDay.AddDays(1))
{
var dayRevenue = dailyRevenueMap.GetValueOrDefault(ToUtcDate(businessDay), 0m);
foreach (var category in CostCategoryOrder)
{
var key = $"{businessDay:yyyyMMdd}:{(int)category}";
decimal amount;
if (overrideMap.TryGetValue(key, out var overrideAmount))
{
amount = overrideAmount;
}
else
{
var profile = profiles.FirstOrDefault(item =>
item.Category == category &&
item.EffectiveFrom.Date <= businessDay &&
(!item.EffectiveTo.HasValue || item.EffectiveTo.Value.Date >= businessDay));
var defaults = DefaultCostProfileMap[category];
var mode = profile?.CalcMode ?? defaults.Mode;
var ratio = profile?.Ratio ?? defaults.Ratio;
var fixedDaily = profile?.FixedDailyAmount ?? defaults.Fixed;
amount = mode == FinanceCostCalcMode.FixedDaily ? fixedDaily : dayRevenue * Math.Max(0m, ratio);
}
categoryAmountMap[category] += RoundMoney(amount);
}
}
var totalCostAmount = categoryAmountMap.Sum(item => item.Value);
return CostCategoryOrder.Select(category => new FinanceBusinessReportBreakdownSnapshot
{
Key = category switch
{
FinanceCostCategory.FoodMaterial => "food_material",
FinanceCostCategory.Labor => "labor",
FinanceCostCategory.FixedExpense => "fixed_expense",
FinanceCostCategory.PackagingConsumable => "packaging_consumable",
_ => "food_material"
},
Label = category switch
{
FinanceCostCategory.FoodMaterial => "食材成本",
FinanceCostCategory.Labor => "人工成本",
FinanceCostCategory.FixedExpense => "固定成本",
FinanceCostCategory.PackagingConsumable => "包装成本",
_ => "食材成本"
},
Amount = RoundMoney(categoryAmountMap[category]),
Ratio = totalCostAmount <= 0m ? 0m : RoundRatio(categoryAmountMap[category] / totalCostAmount)
}).ToList();
}
private static List<FinanceBusinessReportKpiSnapshot> BuildKpis(ComputedReportSnapshot current, ComputedReportSnapshot mom, ComputedReportSnapshot yoy)
{
var definitions = new List<(string Key, string Label, decimal Current, decimal PrevMom, decimal PrevYoy)>
{
("revenue", "营业额", current.RevenueAmount, mom.RevenueAmount, yoy.RevenueAmount),
("order_count", "订单数", current.OrderCount, mom.OrderCount, yoy.OrderCount),
("average_order_value", "客单价", current.AverageOrderValue, mom.AverageOrderValue, yoy.AverageOrderValue),
("refund_rate", "退款率", current.RefundRate, mom.RefundRate, yoy.RefundRate),
("net_profit", "净利润", current.NetProfitAmount, mom.NetProfitAmount, yoy.NetProfitAmount),
("profit_rate", "利润率", current.ProfitRate, mom.ProfitRate, yoy.ProfitRate)
};
return definitions.Select(item => new FinanceBusinessReportKpiSnapshot
{
Key = item.Key,
Label = item.Label,
Value = item.Current,
MomChangeRate = CalculateChangeRate(item.Current, item.PrevMom),
YoyChangeRate = CalculateChangeRate(item.Current, item.PrevYoy)
}).ToList();
}
private static (DateTime StartAt, DateTime EndAt) ResolvePreviousPeriod(FinanceBusinessReportPeriodType periodType, DateTime startAt, DateTime endAt)
{
return periodType switch
{
FinanceBusinessReportPeriodType.Daily => (startAt.AddDays(-1), endAt.AddDays(-1)),
FinanceBusinessReportPeriodType.Weekly => (startAt.AddDays(-7), endAt.AddDays(-7)),
FinanceBusinessReportPeriodType.Monthly => (startAt.AddMonths(-1), endAt.AddMonths(-1)),
_ => (startAt.AddDays(-1), endAt.AddDays(-1))
};
}
private static List<(DateTime StartAt, DateTime EndAt)> BuildPagedPeriods(FinanceBusinessReportPeriodType periodType, int page, int pageSize, DateTime now)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
var offsetStart = (normalizedPage - 1) * normalizedPageSize;
var today = ToUtcDate(now);
var list = new List<(DateTime StartAt, DateTime EndAt)>(normalizedPageSize);
for (var index = 0; index < normalizedPageSize; index++)
{
var offset = offsetStart + index;
if (periodType == FinanceBusinessReportPeriodType.Weekly)
{
var weekStart = GetWeekStart(today).AddDays(-7 * offset);
list.Add((weekStart, weekStart.AddDays(7)));
}
else if (periodType == FinanceBusinessReportPeriodType.Monthly)
{
var monthStart = new DateTime(today.Year, today.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-offset);
list.Add((monthStart, monthStart.AddMonths(1)));
}
else
{
var dayStart = today.AddDays(-offset);
list.Add((dayStart, dayStart.AddDays(1)));
}
}
return list;
}
private static List<TItem> Deserialize<TItem>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<TItem>>(json, JsonOptions) ?? [];
}
catch
{
return [];
}
}
private sealed record ComputedReportSnapshot
{
public decimal RevenueAmount { get; init; }
public int OrderCount { get; init; }
public decimal AverageOrderValue { get; init; }
public decimal RefundRate { get; init; }
public decimal CostTotalAmount { get; init; }
public decimal NetProfitAmount { get; init; }
public decimal ProfitRate { get; init; }
public List<FinanceBusinessReportKpiSnapshot> Kpis { get; init; } = [];
public List<FinanceBusinessReportBreakdownSnapshot> IncomeBreakdowns { get; init; } = [];
public List<FinanceBusinessReportBreakdownSnapshot> CostBreakdowns { get; init; } = [];
}
private static decimal CalculateChangeRate(decimal currentValue, decimal previousValue) => previousValue <= 0m ? (currentValue <= 0m ? 0m : 100m) : RoundRate((currentValue - previousValue) / previousValue * 100m);
private static decimal RoundMoney(decimal value) => decimal.Round(value, 2, MidpointRounding.AwayFromZero);
private static decimal RoundRate(decimal value) => decimal.Round(value, 2, MidpointRounding.AwayFromZero);
private static decimal RoundRatio(decimal value) => decimal.Round(value, 4, MidpointRounding.AwayFromZero);
private static DateTime ToUtcDate(DateTime value) => new(value.Year, value.Month, value.Day, 0, 0, 0, DateTimeKind.Utc);
private static DateTime GetWeekStart(DateTime date) => date.AddDays(0 - (((int)date.DayOfWeek + 6) % 7));
}

View File

@@ -1,615 +0,0 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Domain.Orders.Enums;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 财务概览 EF Core 仓储实现。
/// </summary>
public sealed class EfFinanceOverviewRepository(TakeoutAppDbContext context) : IFinanceOverviewRepository
{
private const int TrendDays = 30;
private const int TopProductDays = 30;
/// <inheritdoc />
public async Task<FinanceOverviewDashboardSnapshot> GetDashboardSnapshotAsync(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime currentUtc,
CancellationToken cancellationToken = default)
{
var utcNow = NormalizeUtc(currentUtc);
var todayStart = NormalizeDate(utcNow);
var tomorrowStart = todayStart.AddDays(1);
var yesterdayStart = todayStart.AddDays(-1);
var trendStart = todayStart.AddDays(0 - TrendDays + 1);
var weekStart = todayStart.AddDays(-7);
// 1. 查询支付与退款基础数据。
var paidQuery = BuildPaidRecordQuery(tenantId, dimension, storeId);
var refundQuery = BuildRefundRecordQuery(tenantId, dimension, storeId);
// 2. 读取近 30 天按日按门店支付与退款汇总。
var paidDailyRows = await paidQuery
.Where(item => item.OccurredAt >= trendStart && item.OccurredAt < tomorrowStart)
.GroupBy(item => new { Date = item.OccurredAt.Date, item.StoreId })
.Select(group => new DailyStoreAmountRow
{
BusinessDate = group.Key.Date,
StoreId = group.Key.StoreId,
Amount = group.Sum(item => item.Amount)
})
.ToListAsync(cancellationToken);
var refundDailyRows = await refundQuery
.Where(item => item.OccurredAt >= trendStart && item.OccurredAt < tomorrowStart)
.GroupBy(item => new { Date = item.OccurredAt.Date, item.StoreId })
.Select(group => new DailyStoreAmountRow
{
BusinessDate = group.Key.Date,
StoreId = group.Key.StoreId,
Amount = group.Sum(item => item.Amount)
})
.ToListAsync(cancellationToken);
// 3. 读取今日收入构成(渠道维度)。
var paidTodayByChannel = await paidQuery
.Where(item => item.OccurredAt >= todayStart && item.OccurredAt < tomorrowStart)
.GroupBy(item => item.DeliveryType)
.Select(group => new DailyChannelAmountRow
{
Channel = group.Key,
Amount = group.Sum(item => item.Amount)
})
.ToListAsync(cancellationToken);
var refundTodayByChannel = await refundQuery
.Where(item => item.OccurredAt >= todayStart && item.OccurredAt < tomorrowStart)
.GroupBy(item => item.DeliveryType)
.Select(group => new DailyChannelAmountRow
{
Channel = group.Key,
Amount = group.Sum(item => item.Amount)
})
.ToListAsync(cancellationToken);
// 4. 读取作用域门店平台费率。
var scopedStoreIds = paidDailyRows
.Select(item => item.StoreId)
.Concat(refundDailyRows.Select(item => item.StoreId))
.Distinct()
.ToList();
if (dimension == FinanceCostDimension.Store && storeId.HasValue && !scopedStoreIds.Contains(storeId.Value))
{
scopedStoreIds.Add(storeId.Value);
}
var platformRateMap = scopedStoreIds.Count == 0
? new Dictionary<long, decimal>()
: await context.StoreFees
.AsNoTracking()
.Where(item => item.TenantId == tenantId && scopedStoreIds.Contains(item.StoreId))
.Select(item => new { item.StoreId, item.PlatformServiceRate })
.ToDictionaryAsync(item => item.StoreId, item => item.PlatformServiceRate, cancellationToken);
// 5. 近 30 天按日收入/退款/平台成本汇总。
var grossByDate = new Dictionary<DateTime, decimal>();
var refundByDate = new Dictionary<DateTime, decimal>();
var platformCostByDate = new Dictionary<DateTime, decimal>();
foreach (var row in paidDailyRows)
{
var businessDate = NormalizeDate(row.BusinessDate);
AddAmount(grossByDate, businessDate, row.Amount);
var platformRate = ResolvePlatformRate(platformRateMap, row.StoreId);
AddAmount(platformCostByDate, businessDate, row.Amount * platformRate / 100m);
}
foreach (var row in refundDailyRows)
{
var businessDate = NormalizeDate(row.BusinessDate);
AddAmount(refundByDate, businessDate, row.Amount);
var platformRate = ResolvePlatformRate(platformRateMap, row.StoreId);
AddAmount(platformCostByDate, businessDate, 0m - row.Amount * platformRate / 100m);
}
// 6. 读取近 30 天覆盖月份的月度成本录入,并折算为日成本。
var monthStarts = BuildMonthStartRange(trendStart, todayStart);
var monthlyCostRows = await BuildMonthlyCostQuery(tenantId, dimension, storeId, monthStarts)
.Select(item => new MonthlyCostRow
{
CostMonth = item.CostMonth,
Category = item.Category,
Amount = item.TotalAmount
})
.ToListAsync(cancellationToken);
var monthlyCostMap = BuildMonthlyCostMap(monthlyCostRows);
// 7. 组装趋势数据与今日/昨日成本构成。
var incomeTrend = new List<FinanceOverviewIncomeTrendPointSnapshot>(TrendDays);
var profitTrend = new List<FinanceOverviewProfitTrendPointSnapshot>(TrendDays);
var todayCostComposition = CreateEmptyCostComposition();
var yesterdayCostComposition = CreateEmptyCostComposition();
for (var currentDate = trendStart; currentDate <= todayStart; currentDate = currentDate.AddDays(1))
{
var grossAmount = RoundAmount(GetAmount(grossByDate, currentDate));
var refundAmount = RoundAmount(GetAmount(refundByDate, currentDate));
var netReceived = RoundAmount(grossAmount - refundAmount);
var platformCost = RoundAmount(GetAmount(platformCostByDate, currentDate));
var baseCost = GetDailyBaseCost(currentDate, monthlyCostMap);
var totalCost = RoundAmount(baseCost.TotalCost + platformCost);
var netProfit = RoundAmount(netReceived - totalCost);
incomeTrend.Add(new FinanceOverviewIncomeTrendPointSnapshot
{
BusinessDate = currentDate,
NetReceivedAmount = netReceived
});
profitTrend.Add(new FinanceOverviewProfitTrendPointSnapshot
{
BusinessDate = currentDate,
RevenueAmount = grossAmount,
CostAmount = totalCost,
NetProfitAmount = netProfit
});
if (currentDate == todayStart)
{
todayCostComposition = new DailyCostAmounts
{
Food = baseCost.Food,
Labor = baseCost.Labor,
Fixed = baseCost.Fixed,
Packaging = baseCost.Packaging,
Platform = platformCost
};
}
if (currentDate == yesterdayStart)
{
yesterdayCostComposition = new DailyCostAmounts
{
Food = baseCost.Food,
Labor = baseCost.Labor,
Fixed = baseCost.Fixed,
Packaging = baseCost.Packaging,
Platform = platformCost
};
}
}
// 8. 汇总核心指标。
var todayGrossRevenue = RoundAmount(GetAmount(grossByDate, todayStart));
var yesterdayGrossRevenue = RoundAmount(GetAmount(grossByDate, yesterdayStart));
var todayRefundAmount = RoundAmount(GetAmount(refundByDate, todayStart));
var yesterdayRefundAmount = RoundAmount(GetAmount(refundByDate, yesterdayStart));
var todayNetReceived = RoundAmount(todayGrossRevenue - todayRefundAmount);
var yesterdayNetReceived = RoundAmount(yesterdayGrossRevenue - yesterdayRefundAmount);
var todayTotalCost = RoundAmount(todayCostComposition.TotalCost);
var yesterdayTotalCost = RoundAmount(yesterdayCostComposition.TotalCost);
var paidTotalAmount = await paidQuery
.Where(item => item.OccurredAt < tomorrowStart)
.Select(item => item.Amount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
var refundTotalAmount = await refundQuery
.Where(item => item.OccurredAt < tomorrowStart)
.Select(item => item.Amount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
var paidBeforeWeekAmount = await paidQuery
.Where(item => item.OccurredAt < weekStart)
.Select(item => item.Amount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
var refundBeforeWeekAmount = await refundQuery
.Where(item => item.OccurredAt < weekStart)
.Select(item => item.Amount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
var withdrawableBalance = RoundAmount(paidTotalAmount - refundTotalAmount);
var withdrawableBalanceLastWeek = RoundAmount(paidBeforeWeekAmount - refundBeforeWeekAmount);
// 9. 收入构成映射(今日)。
var paidChannelMap = paidTodayByChannel.ToDictionary(item => item.Channel, item => item.Amount);
var refundChannelMap = refundTodayByChannel.ToDictionary(item => item.Channel, item => item.Amount);
var incomeComposition = new List<FinanceOverviewIncomeCompositionSnapshot>(3);
foreach (var channel in new[] { DeliveryType.Delivery, DeliveryType.Pickup, DeliveryType.DineIn })
{
paidChannelMap.TryGetValue(channel, out var paidAmount);
refundChannelMap.TryGetValue(channel, out var refundAmount);
incomeComposition.Add(new FinanceOverviewIncomeCompositionSnapshot
{
Channel = channel,
Amount = RoundAmount(Math.Max(0m, paidAmount - refundAmount))
});
}
// 10. 成本构成映射(今日)。
var costComposition = new List<FinanceOverviewCostCompositionSnapshot>
{
new() { CategoryCode = "food", Amount = RoundAmount(todayCostComposition.Food) },
new() { CategoryCode = "labor", Amount = RoundAmount(todayCostComposition.Labor) },
new() { CategoryCode = "fixed", Amount = RoundAmount(todayCostComposition.Fixed) },
new() { CategoryCode = "packaging", Amount = RoundAmount(todayCostComposition.Packaging) },
new() { CategoryCode = "platform", Amount = RoundAmount(todayCostComposition.Platform) }
};
// 11. 查询 TOP10 商品营收排行(固定近 30 天)。
var topRangeStart = todayStart.AddDays(0 - TopProductDays + 1);
var paidOrderIdsQuery = BuildPaidOrderIdsQuery(
tenantId,
dimension,
storeId,
topRangeStart,
tomorrowStart);
var topProductQuery = context.OrderItems
.AsNoTracking()
.Where(item => item.TenantId == tenantId && paidOrderIdsQuery.Contains(item.OrderId))
.GroupBy(item => new { item.ProductId, item.ProductName })
.Select(group => new FinanceOverviewTopProductSnapshot
{
ProductId = group.Key.ProductId,
ProductName = group.Key.ProductName,
SalesQuantity = group.Sum(item => item.Quantity),
RevenueAmount = group.Sum(item => item.SubTotal)
});
var topProductTotalRevenue = await topProductQuery
.Select(item => item.RevenueAmount)
.DefaultIfEmpty(0m)
.SumAsync(cancellationToken);
var topProducts = await topProductQuery
.OrderByDescending(item => item.RevenueAmount)
.ThenByDescending(item => item.SalesQuantity)
.Take(10)
.ToListAsync(cancellationToken);
return new FinanceOverviewDashboardSnapshot
{
Dimension = dimension,
StoreId = dimension == FinanceCostDimension.Store ? storeId : null,
Summary = new FinanceOverviewSummarySnapshot
{
TodayGrossRevenue = todayGrossRevenue,
YesterdayGrossRevenue = yesterdayGrossRevenue,
TodayNetReceived = todayNetReceived,
YesterdayNetReceived = yesterdayNetReceived,
TodayRefundAmount = todayRefundAmount,
YesterdayRefundAmount = yesterdayRefundAmount,
TodayTotalCost = todayTotalCost,
YesterdayTotalCost = yesterdayTotalCost,
WithdrawableBalance = withdrawableBalance,
WithdrawableBalanceLastWeek = withdrawableBalanceLastWeek
},
IncomeTrend = incomeTrend,
ProfitTrend = profitTrend,
IncomeComposition = incomeComposition,
CostComposition = costComposition,
TopProducts = topProducts,
TopProductTotalRevenue = RoundAmount(topProductTotalRevenue)
};
}
private IQueryable<PaidRecordProjection> BuildPaidRecordQuery(
long tenantId,
FinanceCostDimension dimension,
long? storeId)
{
var query =
from payment in context.PaymentRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on payment.OrderId equals order.Id
where payment.TenantId == tenantId
&& order.TenantId == tenantId
&& payment.Status == PaymentStatus.Paid
&& payment.PaidAt.HasValue
select new PaidRecordProjection
{
StoreId = order.StoreId,
DeliveryType = order.DeliveryType,
Amount = payment.Amount,
OccurredAt = payment.PaidAt!.Value
};
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
query = query.Where(item => item.StoreId == storeId.Value);
}
return query;
}
private IQueryable<RefundRecordProjection> BuildRefundRecordQuery(
long tenantId,
FinanceCostDimension dimension,
long? storeId)
{
var query =
from refund in context.PaymentRefundRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on refund.OrderId equals order.Id
where refund.TenantId == tenantId
&& order.TenantId == tenantId
&& refund.Status == PaymentRefundStatus.Succeeded
select new RefundRecordProjection
{
StoreId = order.StoreId,
DeliveryType = order.DeliveryType,
Amount = refund.Amount,
OccurredAt = refund.CompletedAt ?? refund.RequestedAt
};
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
query = query.Where(item => item.StoreId == storeId.Value);
}
return query;
}
private IQueryable<long> BuildPaidOrderIdsQuery(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
DateTime startAt,
DateTime endAt)
{
var query =
from payment in context.PaymentRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on payment.OrderId equals order.Id
where payment.TenantId == tenantId
&& order.TenantId == tenantId
&& payment.Status == PaymentStatus.Paid
&& payment.PaidAt.HasValue
&& payment.PaidAt.Value >= startAt
&& payment.PaidAt.Value < endAt
select new
{
order.Id,
order.StoreId
};
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
query = query.Where(item => item.StoreId == storeId.Value);
}
return query.Select(item => item.Id).Distinct();
}
private IQueryable<TakeoutSaaS.Domain.Finance.Entities.FinanceCostEntry> BuildMonthlyCostQuery(
long tenantId,
FinanceCostDimension dimension,
long? storeId,
IReadOnlyCollection<DateTime> monthStarts)
{
var query = context.FinanceCostEntries
.AsNoTracking()
.Where(item => item.TenantId == tenantId && monthStarts.Contains(item.CostMonth));
if (dimension == FinanceCostDimension.Store && storeId.HasValue)
{
return query.Where(item =>
item.Dimension == FinanceCostDimension.Store &&
item.StoreId == storeId.Value);
}
return query.Where(item =>
item.Dimension == FinanceCostDimension.Tenant &&
item.StoreId == null);
}
private static Dictionary<DateTime, Dictionary<FinanceCostCategory, decimal>> BuildMonthlyCostMap(
IReadOnlyCollection<MonthlyCostRow> rows)
{
var result = new Dictionary<DateTime, Dictionary<FinanceCostCategory, decimal>>();
foreach (var row in rows)
{
var monthStart = NormalizeMonthStart(row.CostMonth);
if (!result.TryGetValue(monthStart, out var categoryMap))
{
categoryMap = new Dictionary<FinanceCostCategory, decimal>();
result[monthStart] = categoryMap;
}
if (!categoryMap.ContainsKey(row.Category))
{
categoryMap[row.Category] = 0m;
}
categoryMap[row.Category] += row.Amount;
}
return result;
}
private static DailyCostAmounts GetDailyBaseCost(
DateTime businessDate,
IReadOnlyDictionary<DateTime, Dictionary<FinanceCostCategory, decimal>> monthlyCostMap)
{
var monthStart = NormalizeMonthStart(businessDate);
var daysInMonth = DateTime.DaysInMonth(monthStart.Year, monthStart.Month);
monthlyCostMap.TryGetValue(monthStart, out var categoryMap);
categoryMap ??= new Dictionary<FinanceCostCategory, decimal>();
var food = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.FoodMaterial, daysInMonth);
var labor = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.Labor, daysInMonth);
var fixedCost = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.FixedExpense, daysInMonth);
var packaging = ResolveDailyCategoryAmount(categoryMap, FinanceCostCategory.PackagingConsumable, daysInMonth);
return new DailyCostAmounts
{
Food = food,
Labor = labor,
Fixed = fixedCost,
Packaging = packaging,
Platform = 0m
};
}
private static decimal ResolveDailyCategoryAmount(
IReadOnlyDictionary<FinanceCostCategory, decimal> categoryMap,
FinanceCostCategory category,
int daysInMonth)
{
categoryMap.TryGetValue(category, out var monthTotal);
if (daysInMonth <= 0)
{
return 0m;
}
return RoundAmount(monthTotal / daysInMonth);
}
private static List<DateTime> BuildMonthStartRange(DateTime startDate, DateTime endDate)
{
var result = new List<DateTime>();
var monthStart = NormalizeMonthStart(startDate);
var endMonth = NormalizeMonthStart(endDate);
while (monthStart <= endMonth)
{
result.Add(monthStart);
monthStart = monthStart.AddMonths(1);
}
return result;
}
private static DailyCostAmounts CreateEmptyCostComposition()
{
return new DailyCostAmounts
{
Food = 0m,
Labor = 0m,
Fixed = 0m,
Packaging = 0m,
Platform = 0m
};
}
private static void AddAmount(IDictionary<DateTime, decimal> map, DateTime key, decimal amount)
{
if (!map.ContainsKey(key))
{
map[key] = 0m;
}
map[key] += amount;
}
private static decimal GetAmount(IReadOnlyDictionary<DateTime, decimal> map, DateTime key)
{
return map.TryGetValue(key, out var value) ? value : 0m;
}
private static decimal ResolvePlatformRate(IReadOnlyDictionary<long, decimal> map, long storeId)
{
if (!map.TryGetValue(storeId, out var rate))
{
return 0m;
}
return Math.Clamp(rate, 0m, 100m);
}
private static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
private static DateTime NormalizeDate(DateTime value)
{
var utc = NormalizeUtc(value);
return new DateTime(utc.Year, utc.Month, utc.Day, 0, 0, 0, DateTimeKind.Utc);
}
private static DateTime NormalizeMonthStart(DateTime value)
{
var utc = NormalizeUtc(value);
return new DateTime(utc.Year, utc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
}
private static decimal RoundAmount(decimal value)
{
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
private sealed class PaidRecordProjection
{
public required long StoreId { get; init; }
public required DeliveryType DeliveryType { get; init; }
public required decimal Amount { get; init; }
public required DateTime OccurredAt { get; init; }
}
private sealed class RefundRecordProjection
{
public required long StoreId { get; init; }
public required DeliveryType DeliveryType { get; init; }
public required decimal Amount { get; init; }
public required DateTime OccurredAt { get; init; }
}
private sealed class DailyStoreAmountRow
{
public required DateTime BusinessDate { get; init; }
public required long StoreId { get; init; }
public required decimal Amount { get; init; }
}
private sealed class DailyChannelAmountRow
{
public required DeliveryType Channel { get; init; }
public required decimal Amount { get; init; }
}
private sealed class MonthlyCostRow
{
public required DateTime CostMonth { get; init; }
public required FinanceCostCategory Category { get; init; }
public required decimal Amount { get; init; }
}
private sealed class DailyCostAmounts
{
public decimal Food { get; init; }
public decimal Labor { get; init; }
public decimal Fixed { get; init; }
public decimal Packaging { get; init; }
public decimal Platform { get; init; }
public decimal TotalCost => RoundAmount(Food + Labor + Fixed + Packaging + Platform);
}
}

View File

@@ -1,303 +0,0 @@
using ClosedXML.Excel;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using System.Globalization;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
using TakeoutSaaS.Domain.Finance.Services;
namespace TakeoutSaaS.Infrastructure.App.Services;
/// <summary>
/// 经营报表导出服务实现PDF / Excel
/// </summary>
public sealed class FinanceBusinessReportExportService : IFinanceBusinessReportExportService
{
public FinanceBusinessReportExportService()
{
QuestPDF.Settings.License = LicenseType.Community;
}
/// <inheritdoc />
public Task<byte[]> ExportSinglePdfAsync(
FinanceBusinessReportDetailSnapshot detail,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(detail);
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(BuildPdf([detail]));
}
/// <inheritdoc />
public Task<byte[]> ExportSingleExcelAsync(
FinanceBusinessReportDetailSnapshot detail,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(detail);
cancellationToken.ThrowIfCancellationRequested();
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("经营报表");
WriteDetailWorksheet(worksheet, detail);
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return Task.FromResult(stream.ToArray());
}
/// <inheritdoc />
public Task<byte[]> ExportBatchPdfAsync(
IReadOnlyList<FinanceBusinessReportDetailSnapshot> details,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(details);
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(BuildPdf(details));
}
/// <inheritdoc />
public Task<byte[]> ExportBatchExcelAsync(
IReadOnlyList<FinanceBusinessReportDetailSnapshot> details,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(details);
cancellationToken.ThrowIfCancellationRequested();
using var workbook = new XLWorkbook();
var summary = workbook.Worksheets.Add("汇总");
WriteSummaryWorksheet(summary, details);
for (var index = 0; index < details.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var detail = details[index];
var sheetName = $"报表{index + 1:D2}";
var worksheet = workbook.Worksheets.Add(sheetName);
WriteDetailWorksheet(worksheet, detail);
}
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return Task.FromResult(stream.ToArray());
}
private static byte[] BuildPdf(IReadOnlyList<FinanceBusinessReportDetailSnapshot> details)
{
var source = details.Count == 0
? [new FinanceBusinessReportDetailSnapshot()]
: details;
var document = Document.Create(container =>
{
foreach (var detail in source)
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(24);
page.DefaultTextStyle(x => x.FontSize(10));
page.Content().Column(column =>
{
column.Spacing(8);
column.Item().Text(BuildTitle(detail)).FontSize(16).SemiBold();
column.Item().Text($"状态:{ResolveStatusText(detail.Status)}");
column.Item().Element(section => BuildKpiSection(section, detail.Kpis));
column.Item().Element(section => BuildBreakdownSection(section, "收入明细(按渠道)", detail.IncomeBreakdowns));
column.Item().Element(section => BuildBreakdownSection(section, "成本明细(按类别)", detail.CostBreakdowns));
});
});
}
});
return document.GeneratePdf();
}
private static void BuildKpiSection(IContainer container, IReadOnlyList<FinanceBusinessReportKpiSnapshot> kpis)
{
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
{
column.Spacing(4);
column.Item().Text("关键指标").SemiBold();
if (kpis.Count == 0)
{
column.Item().Text("暂无数据");
return;
}
foreach (var item in kpis)
{
column.Item().Row(row =>
{
row.RelativeItem().Text(item.Label);
row.RelativeItem().AlignRight().Text(FormatKpiValue(item.Key, item.Value));
row.RelativeItem().AlignRight().Text(
$"同比 {FormatSignedRate(item.YoyChangeRate)} | 环比 {FormatSignedRate(item.MomChangeRate)}");
});
}
});
}
private static void BuildBreakdownSection(
IContainer container,
string title,
IReadOnlyList<FinanceBusinessReportBreakdownSnapshot> rows)
{
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
{
column.Spacing(4);
column.Item().Text(title).SemiBold();
if (rows.Count == 0)
{
column.Item().Text("暂无数据");
return;
}
foreach (var item in rows)
{
column.Item().Row(row =>
{
row.RelativeItem().Text(item.Label);
row.ConstantItem(80).AlignRight().Text(FormatPercent(item.Ratio));
row.ConstantItem(120).AlignRight().Text(FormatCurrency(item.Amount));
});
}
});
}
private static void WriteSummaryWorksheet(
IXLWorksheet worksheet,
IReadOnlyList<FinanceBusinessReportDetailSnapshot> details)
{
worksheet.Cell(1, 1).Value = "报表标题";
worksheet.Cell(1, 2).Value = "状态";
worksheet.Cell(1, 3).Value = "营业额";
worksheet.Cell(1, 4).Value = "订单数";
worksheet.Cell(1, 5).Value = "客单价";
worksheet.Cell(1, 6).Value = "退款率";
worksheet.Cell(1, 7).Value = "成本总额";
worksheet.Cell(1, 8).Value = "净利润";
worksheet.Cell(1, 9).Value = "利润率";
for (var index = 0; index < details.Count; index++)
{
var row = index + 2;
var detail = details[index];
worksheet.Cell(row, 1).Value = BuildTitle(detail);
worksheet.Cell(row, 2).Value = ResolveStatusText(detail.Status);
worksheet.Cell(row, 3).Value = detail.RevenueAmount;
worksheet.Cell(row, 4).Value = detail.OrderCount;
worksheet.Cell(row, 5).Value = detail.AverageOrderValue;
worksheet.Cell(row, 6).Value = detail.RefundRate;
worksheet.Cell(row, 7).Value = detail.CostTotalAmount;
worksheet.Cell(row, 8).Value = detail.NetProfitAmount;
worksheet.Cell(row, 9).Value = detail.ProfitRate;
}
worksheet.Columns().AdjustToContents();
}
private static void WriteDetailWorksheet(
IXLWorksheet worksheet,
FinanceBusinessReportDetailSnapshot detail)
{
var row = 1;
worksheet.Cell(row, 1).Value = BuildTitle(detail);
worksheet.Range(row, 1, row, 4).Merge().Style.Font.SetBold();
row += 2;
worksheet.Cell(row, 1).Value = "关键指标";
worksheet.Cell(row, 1).Style.Font.SetBold();
row += 1;
worksheet.Cell(row, 1).Value = "指标";
worksheet.Cell(row, 2).Value = "值";
worksheet.Cell(row, 3).Value = "同比";
worksheet.Cell(row, 4).Value = "环比";
row += 1;
foreach (var item in detail.Kpis)
{
worksheet.Cell(row, 1).Value = item.Label;
worksheet.Cell(row, 2).Value = FormatKpiValue(item.Key, item.Value);
worksheet.Cell(row, 3).Value = FormatSignedRate(item.YoyChangeRate);
worksheet.Cell(row, 4).Value = FormatSignedRate(item.MomChangeRate);
row += 1;
}
row += 1;
row = WriteBreakdownTable(worksheet, row, "收入明细(按渠道)", detail.IncomeBreakdowns);
row += 1;
_ = WriteBreakdownTable(worksheet, row, "成本明细(按类别)", detail.CostBreakdowns);
worksheet.Columns().AdjustToContents();
}
private static int WriteBreakdownTable(
IXLWorksheet worksheet,
int startRow,
string title,
IReadOnlyList<FinanceBusinessReportBreakdownSnapshot> rows)
{
var row = startRow;
worksheet.Cell(row, 1).Value = title;
worksheet.Cell(row, 1).Style.Font.SetBold();
row += 1;
worksheet.Cell(row, 1).Value = "名称";
worksheet.Cell(row, 2).Value = "占比";
worksheet.Cell(row, 3).Value = "金额";
row += 1;
foreach (var item in rows)
{
worksheet.Cell(row, 1).Value = item.Label;
worksheet.Cell(row, 2).Value = FormatPercent(item.Ratio);
worksheet.Cell(row, 3).Value = item.Amount;
row += 1;
}
return row;
}
private static string FormatCurrency(decimal value) => $"¥{value:0.##}";
private static string FormatPercent(decimal ratioValue) => $"{ratioValue * 100m:0.##}%";
private static string FormatSignedRate(decimal rate) => $"{(rate >= 0m ? "+" : string.Empty)}{rate:0.##}%";
private static string BuildTitle(FinanceBusinessReportDetailSnapshot detail) => detail.PeriodType switch
{
FinanceBusinessReportPeriodType.Daily => $"{detail.PeriodStartAt:yyyy年M月d日} 经营日报",
FinanceBusinessReportPeriodType.Weekly => $"{detail.PeriodStartAt:yyyy年M月d日}~{detail.PeriodEndAt.AddDays(-1):M月d日} 经营周报",
FinanceBusinessReportPeriodType.Monthly => $"{detail.PeriodStartAt:yyyy年M月} 经营月报",
_ => detail.PeriodStartAt == default
? "经营报表"
: detail.PeriodStartAt.ToString("yyyy-MM-dd 经营报表", CultureInfo.InvariantCulture)
};
private static string ResolveStatusText(FinanceBusinessReportStatus status) => status switch
{
FinanceBusinessReportStatus.Queued => "排队中",
FinanceBusinessReportStatus.Running => "生成中",
FinanceBusinessReportStatus.Succeeded => "已生成",
FinanceBusinessReportStatus.Failed => "生成失败",
_ => "未知"
};
private static string FormatKpiValue(string key, decimal value)
{
if (key == "order_count")
{
return $"{decimal.Round(value, 0, MidpointRounding.AwayFromZero):0}";
}
if (key is "refund_rate" or "profit_rate")
{
return $"{value * 100m:0.##}%";
}
return FormatCurrency(value);
}
}

View File

@@ -1,214 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddFinanceInvoiceModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "finance_cost_entries",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Dimension = table.Column<int>(type: "integer", nullable: false, comment: "统计维度。"),
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识(租户汇总维度为空)。"),
CostMonth = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "成本月份(统一存储为 UTC 每月第一天 00:00:00。"),
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
TotalAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "分类总金额。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_cost_entries", x => x.Id);
},
comment: "成本录入月度汇总实体(按维度 + 分类)。");
migrationBuilder.CreateTable(
name: "finance_invoice_records",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
InvoiceNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "发票号码。"),
ApplicantName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "申请人。"),
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "开票抬头(公司名)。"),
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "纳税人识别号快照。"),
InvoiceType = table.Column<int>(type: "integer", nullable: false, comment: "发票类型。"),
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "开票金额。"),
OrderNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "关联订单号。"),
ContactEmail = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "接收邮箱。"),
ContactPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "联系电话。"),
ApplyRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "申请备注。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "发票状态。"),
AppliedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "申请时间UTC。"),
IssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "开票时间UTC。"),
IssuedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "开票人 ID。"),
IssueRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "开票备注。"),
VoidedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "作废时间UTC。"),
VoidedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "作废人 ID。"),
VoidReason = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "作废原因。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_invoice_records", x => x.Id);
},
comment: "租户发票记录。");
migrationBuilder.CreateTable(
name: "finance_invoice_settings",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "企业名称。"),
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "纳税人识别号。"),
RegisteredAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "注册地址。"),
RegisteredPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "注册电话。"),
BankName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "开户银行。"),
BankAccount = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "银行账号。"),
EnableElectronicNormalInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子普通发票。"),
EnableElectronicSpecialInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子专用发票。"),
EnableAutoIssue = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用自动开票。"),
AutoIssueMaxAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "自动开票单张最大金额。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_invoice_settings", x => x.Id);
},
comment: "租户发票开票基础设置。");
migrationBuilder.CreateTable(
name: "finance_cost_entry_items",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
EntryId = table.Column<long>(type: "bigint", nullable: false, comment: "关联汇总行标识。"),
Dimension = table.Column<int>(type: "integer", nullable: false, comment: "统计维度。"),
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识(租户汇总维度为空)。"),
CostMonth = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "成本月份(统一存储为 UTC 每月第一天 00:00:00。"),
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
ItemName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "明细名称。"),
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "明细金额。"),
Quantity = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "数量(人工类可用)。"),
UnitPrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "单价(人工类可用)。"),
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 100, comment: "排序值。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_cost_entry_items", x => x.Id);
table.ForeignKey(
name: "FK_finance_cost_entry_items_finance_cost_entries_EntryId",
column: x => x.EntryId,
principalTable: "finance_cost_entries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
},
comment: "成本录入明细项实体。");
migrationBuilder.CreateIndex(
name: "IX_finance_cost_entries_TenantId_Dimension_StoreId_CostMonth",
table: "finance_cost_entries",
columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth" });
migrationBuilder.CreateIndex(
name: "IX_finance_cost_entries_TenantId_Dimension_StoreId_CostMonth_C~",
table: "finance_cost_entries",
columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth", "Category" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_finance_cost_entry_items_EntryId",
table: "finance_cost_entry_items",
column: "EntryId");
migrationBuilder.CreateIndex(
name: "IX_finance_cost_entry_items_TenantId_Dimension_StoreId_CostMon~",
table: "finance_cost_entry_items",
columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth", "Category", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_InvoiceNo",
table: "finance_invoice_records",
columns: new[] { "TenantId", "InvoiceNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_InvoiceType_AppliedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "InvoiceType", "AppliedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_OrderNo",
table: "finance_invoice_records",
columns: new[] { "TenantId", "OrderNo" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_Status_AppliedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "Status", "AppliedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_Status_IssuedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "Status", "IssuedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_settings_TenantId",
table: "finance_invoice_settings",
column: "TenantId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "finance_cost_entry_items");
migrationBuilder.DropTable(
name: "finance_invoice_records");
migrationBuilder.DropTable(
name: "finance_invoice_settings");
migrationBuilder.DropTable(
name: "finance_cost_entries");
}
}
}

View File

@@ -1,32 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddStoreFeePlatformServiceRate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "PlatformServiceRate",
table: "store_fees",
type: "numeric(5,2)",
precision: 5,
scale: 2,
nullable: false,
defaultValue: 0m,
comment: "平台服务费率(%)。");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PlatformServiceRate",
table: "store_fees");
}
}
}

View File

@@ -1,164 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TakeoutSaaS.Infrastructure.App.Persistence;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations;
/// <summary>
/// 新增经营报表快照与成本配置表结构。
/// </summary>
[DbContext(typeof(TakeoutAppDbContext))]
[Migration("20260305090000_AddFinanceBusinessReportModule")]
public sealed class AddFinanceBusinessReportModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "finance_business_report_snapshots",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "所属门店 ID。"),
PeriodType = table.Column<int>(type: "integer", nullable: false, comment: "周期类型。"),
PeriodStartAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "周期开始时间UTC。"),
PeriodEndAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "周期结束时间UTC不含。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "生成状态。"),
RevenueAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "营业额。"),
OrderCount = table.Column<int>(type: "integer", nullable: false, comment: "订单数。"),
AverageOrderValue = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "客单价。"),
RefundRate = table.Column<decimal>(type: "numeric(9,4)", precision: 9, scale: 4, nullable: false, comment: "退款率0-1。"),
CostTotalAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "成本总额。"),
NetProfitAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "净利润。"),
ProfitRate = table.Column<decimal>(type: "numeric(9,4)", precision: 9, scale: 4, nullable: false, comment: "利润率0-1。"),
KpiComparisonJson = table.Column<string>(type: "text", nullable: false, comment: "KPI 比较快照 JSON同比/环比)。"),
IncomeBreakdownJson = table.Column<string>(type: "text", nullable: false, comment: "收入明细快照 JSON按渠道。"),
CostBreakdownJson = table.Column<string>(type: "text", nullable: false, comment: "成本明细快照 JSON按类别。"),
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "生成开始时间UTC。"),
FinishedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "生成完成时间UTC。"),
LastError = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true, comment: "最近一次失败信息。"),
RetryCount = table.Column<int>(type: "integer", nullable: false, defaultValue: 0, comment: "重试次数。"),
HangfireJobId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "调度任务 ID。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_business_report_snapshots", x => x.Id);
},
comment: "经营报表快照实体。");
migrationBuilder.CreateTable(
name: "finance_cost_profiles",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
CalcMode = table.Column<int>(type: "integer", nullable: false, comment: "计算模式。"),
Ratio = table.Column<decimal>(type: "numeric(9,6)", precision: 9, scale: 6, nullable: false, comment: "比例值0-1。"),
FixedDailyAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "固定日金额。"),
EffectiveFrom = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "生效开始日期UTC 日期)。"),
EffectiveTo = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "生效结束日期UTC 日期,含)。"),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用。"),
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 100, comment: "排序值。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_cost_profiles", x => x.Id);
},
comment: "成本配置实体(类别级规则)。");
migrationBuilder.CreateTable(
name: "finance_cost_daily_overrides",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
BusinessDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "业务日期UTC 日期)。"),
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "覆盖金额。"),
Remark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "备注。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_cost_daily_overrides", x => x.Id);
},
comment: "成本日覆盖实体。");
migrationBuilder.CreateIndex(
name: "IX_finance_business_report_snapshots_TenantId_Status_CreatedAt",
table: "finance_business_report_snapshots",
columns: new[] { "TenantId", "Status", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_business_report_snapshots_TenantId_StoreId_PeriodType_S~",
table: "finance_business_report_snapshots",
columns: new[] { "TenantId", "StoreId", "PeriodType", "Status", "PeriodStartAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_business_report_snapshots_TenantId_StoreId_PeriodType_P~",
table: "finance_business_report_snapshots",
columns: new[] { "TenantId", "StoreId", "PeriodType", "PeriodStartAt" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_finance_cost_daily_overrides_TenantId_StoreId_BusinessDate",
table: "finance_cost_daily_overrides",
columns: new[] { "TenantId", "StoreId", "BusinessDate" });
migrationBuilder.CreateIndex(
name: "IX_finance_cost_daily_overrides_TenantId_StoreId_BusinessDate_Cate~",
table: "finance_cost_daily_overrides",
columns: new[] { "TenantId", "StoreId", "BusinessDate", "Category" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_finance_cost_profiles_TenantId_StoreId_Category_EffectiveFrom_E~",
table: "finance_cost_profiles",
columns: new[] { "TenantId", "StoreId", "Category", "EffectiveFrom", "EffectiveTo" });
migrationBuilder.CreateIndex(
name: "IX_finance_cost_profiles_TenantId_StoreId_IsEnabled_SortOrder",
table: "finance_cost_profiles",
columns: new[] { "TenantId", "StoreId", "IsEnabled", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "finance_business_report_snapshots");
migrationBuilder.DropTable(
name: "finance_cost_daily_overrides");
migrationBuilder.DropTable(
name: "finance_cost_profiles");
}
}

View File

@@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TakeoutSaaS.Infrastructure.App.Persistence;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations;
/// <summary>
/// 新增财务中心发票管理表结构。
/// </summary>
[DbContext(typeof(TakeoutAppDbContext))]
[Migration("20260305103000_AddFinanceInvoiceModule")]
public sealed class AddFinanceInvoiceModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "finance_invoice_records",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
InvoiceNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "发票号码。"),
ApplicantName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "申请人。"),
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "开票抬头(公司名)。"),
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "纳税人识别号快照。"),
InvoiceType = table.Column<int>(type: "integer", nullable: false, comment: "发票类型。"),
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "开票金额。"),
OrderNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "关联订单号。"),
ContactEmail = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "接收邮箱。"),
ContactPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "联系电话。"),
ApplyRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "申请备注。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "发票状态。"),
AppliedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "申请时间UTC。"),
IssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "开票时间UTC。"),
IssuedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "开票人 ID。"),
IssueRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "开票备注。"),
VoidedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "作废时间UTC。"),
VoidedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "作废人 ID。"),
VoidReason = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "作废原因。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_invoice_records", x => x.Id);
},
comment: "租户发票记录。");
migrationBuilder.CreateTable(
name: "finance_invoice_settings",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "企业名称。"),
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "纳税人识别号。"),
RegisteredAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "注册地址。"),
RegisteredPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "注册电话。"),
BankName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "开户银行。"),
BankAccount = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "银行账号。"),
EnableElectronicNormalInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子普通发票。"),
EnableElectronicSpecialInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子专用发票。"),
EnableAutoIssue = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用自动开票。"),
AutoIssueMaxAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "自动开票单张最大金额。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_invoice_settings", x => x.Id);
},
comment: "租户发票开票基础设置。");
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_InvoiceNo",
table: "finance_invoice_records",
columns: new[] { "TenantId", "InvoiceNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_InvoiceType_AppliedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "InvoiceType", "AppliedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_OrderNo",
table: "finance_invoice_records",
columns: new[] { "TenantId", "OrderNo" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_Status_AppliedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "Status", "AppliedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_Status_IssuedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "Status", "IssuedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_settings_TenantId",
table: "finance_invoice_settings",
column: "TenantId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "finance_invoice_records");
migrationBuilder.DropTable(
name: "finance_invoice_settings");
}
}

View File

@@ -1,249 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using TakeoutSaaS.Infrastructure.Identity.Persistence;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb;
/// <summary>
/// 写入经营报表菜单与权限定义。
/// </summary>
[DbContext(typeof(IdentityDbContext))]
[Migration("20260305093000_SeedFinanceReportMenuAndPermissions")]
public sealed class SeedFinanceReportMenuAndPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DO $$
DECLARE
v_parent_permission_id bigint;
v_view_permission_id bigint;
v_export_permission_id bigint;
v_parent_menu_id bigint;
v_report_menu_id bigint;
v_permission_seed_base bigint := 840200000000000000;
v_menu_seed_base bigint := 850200000000000000;
BEGIN
-- 1.
SELECT "Id"
INTO v_parent_permission_id
FROM public.permissions
WHERE "Code" = 'group:tenant:statistics'
ORDER BY "Id"
LIMIT 1;
IF v_parent_permission_id IS NULL THEN
v_parent_permission_id := v_permission_seed_base + 1;
INSERT INTO public.permissions (
"Id", "Name", "Code", "Description",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy",
"ParentId", "SortOrder", "Type", "Portal")
VALUES (
v_parent_permission_id, '', 'group:tenant:statistics', '',
NOW(), NULL, NULL,
NULL, NULL, NULL,
0, 5300, 'group', 1)
ON CONFLICT ("Code") DO NOTHING;
END IF;
-- 2. Upsert
INSERT INTO public.permissions (
"Id", "Name", "Code", "Description",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy",
"ParentId", "SortOrder", "Type", "Portal")
VALUES (
v_permission_seed_base + 11, '', 'tenant:statistics:report:view', '',
NOW(), NULL, NULL,
NULL, NULL, NULL,
v_parent_permission_id, 5310, 'leaf', 1)
ON CONFLICT ("Code") DO UPDATE
SET "Name" = EXCLUDED."Name",
"Description" = EXCLUDED."Description",
"ParentId" = EXCLUDED."ParentId",
"SortOrder" = EXCLUDED."SortOrder",
"Type" = EXCLUDED."Type",
"Portal" = EXCLUDED."Portal",
"DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW();
-- 3. Upsert
INSERT INTO public.permissions (
"Id", "Name", "Code", "Description",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy",
"ParentId", "SortOrder", "Type", "Portal")
VALUES (
v_permission_seed_base + 12, '', 'tenant:statistics:report:export', ' PDF / Excel / ZIP',
NOW(), NULL, NULL,
NULL, NULL, NULL,
v_parent_permission_id, 5320, 'leaf', 1)
ON CONFLICT ("Code") DO UPDATE
SET "Name" = EXCLUDED."Name",
"Description" = EXCLUDED."Description",
"ParentId" = EXCLUDED."ParentId",
"SortOrder" = EXCLUDED."SortOrder",
"Type" = EXCLUDED."Type",
"Portal" = EXCLUDED."Portal",
"DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW();
SELECT "Id" INTO v_view_permission_id
FROM public.permissions
WHERE "Code" = 'tenant:statistics:report:view'
LIMIT 1;
SELECT "Id" INTO v_export_permission_id
FROM public.permissions
WHERE "Code" = 'tenant:statistics:report:export'
LIMIT 1;
-- 4.
SELECT "Id"
INTO v_parent_menu_id
FROM public.menu_definitions
WHERE "Portal" = 1 AND "Path" = '/finance' AND "DeletedAt" IS NULL
ORDER BY "Id"
LIMIT 1;
IF v_parent_menu_id IS NULL THEN
v_parent_menu_id := v_menu_seed_base + 1;
INSERT INTO public.menu_definitions (
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
"IsIframe", "Link", "KeepAlive", "SortOrder",
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
VALUES (
v_parent_menu_id, 0, 'Finance', '/finance', 'BasicLayout', '', 'lucide:wallet',
FALSE, NULL, FALSE, 500,
'', '', '', NULL,
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
ON CONFLICT ("Id") DO NOTHING;
END IF;
-- 5. Upsert
SELECT "Id"
INTO v_report_menu_id
FROM public.menu_definitions
WHERE "Portal" = 1
AND ("Path" = '/finance/report' OR ("Path" = 'report' AND "Component" = '/finance/report/index'))
ORDER BY "DeletedAt" NULLS FIRST, "Id"
LIMIT 1;
IF v_report_menu_id IS NULL THEN
v_report_menu_id := v_menu_seed_base + 11;
INSERT INTO public.menu_definitions (
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
"IsIframe", "Link", "KeepAlive", "SortOrder",
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
VALUES (
v_report_menu_id, v_parent_menu_id, 'BusinessReport', '/finance/report', '/finance/report/index', '', 'lucide:file-bar-chart-2',
FALSE, NULL, TRUE, 530,
'tenant:statistics:report:view', 'tenant:statistics:report:view,tenant:statistics:report:export', '', NULL,
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
ON CONFLICT ("Id") DO NOTHING;
ELSE
UPDATE public.menu_definitions
SET "ParentId" = v_parent_menu_id,
"Name" = 'BusinessReport',
"Path" = '/finance/report',
"Component" = '/finance/report/index',
"Title" = '',
"Icon" = 'lucide:file-bar-chart-2',
"IsIframe" = FALSE,
"Link" = NULL,
"KeepAlive" = TRUE,
"SortOrder" = 530,
"RequiredPermissions" = 'tenant:statistics:report:view',
"MetaPermissions" = 'tenant:statistics:report:view,tenant:statistics:report:export',
"MetaRoles" = '',
"DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW(),
"Portal" = 1
WHERE "Id" = v_report_menu_id;
END IF;
-- 6. tenant-admin
INSERT INTO public.role_permissions (
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
SELECT
ABS(HASHTEXTEXTENDED('tenant-admin:report:' || role."Id"::text || ':' || permission_id::text, 0)),
role."Id",
permission_id,
NOW(), NULL, NULL,
NULL, NULL, NULL,
role."TenantId",
1
FROM public.roles role
CROSS JOIN LATERAL (
SELECT UNNEST(ARRAY[v_view_permission_id, v_export_permission_id]) AS permission_id
) item
WHERE role."Code" = 'tenant-admin'
AND role."DeletedAt" IS NULL
AND item.permission_id IS NOT NULL
ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
SET "DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW(),
"Portal" = 1;
-- 7. tenant-admin
INSERT INTO public.role_template_permissions (
"Id", "RoleTemplateId", "PermissionCode",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy")
SELECT
ABS(HASHTEXTEXTENDED('template-report:' || template."Id"::text || ':' || item.permission_code, 0)),
template."Id",
item.permission_code,
NOW(), NULL, NULL,
NULL, NULL, NULL
FROM public.role_templates template
CROSS JOIN LATERAL (
SELECT UNNEST(ARRAY['tenant:statistics:report:view', 'tenant:statistics:report:export']) AS permission_code
) item
WHERE template."TemplateCode" = 'tenant-admin'
AND template."DeletedAt" IS NULL
ON CONFLICT ("RoleTemplateId", "PermissionCode") DO UPDATE
SET "DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW();
END $$;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DO $$
BEGIN
DELETE FROM public.role_permissions
WHERE "PermissionId" IN (
SELECT "Id"
FROM public.permissions
WHERE "Code" IN ('tenant:statistics:report:view', 'tenant:statistics:report:export'));
DELETE FROM public.role_template_permissions
WHERE "PermissionCode" IN ('tenant:statistics:report:view', 'tenant:statistics:report:export');
DELETE FROM public.menu_definitions
WHERE "Portal" = 1 AND "Path" = '/finance/report';
DELETE FROM public.permissions
WHERE "Code" IN ('tenant:statistics:report:view', 'tenant:statistics:report:export');
END $$;
""");
}
}

View File

@@ -1,215 +0,0 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using TakeoutSaaS.Infrastructure.Identity.Persistence;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb;
/// <summary>
/// 写入财务概览菜单与权限定义。
/// </summary>
[DbContext(typeof(IdentityDbContext))]
[Migration("20260305110000_SeedFinanceOverviewMenuAndPermissions")]
public sealed class SeedFinanceOverviewMenuAndPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DO $$
DECLARE
v_parent_permission_id bigint;
v_view_permission_id bigint;
v_parent_menu_id bigint;
v_overview_menu_id bigint;
v_permission_seed_base bigint := 840300000000000000;
v_menu_seed_base bigint := 850300000000000000;
BEGIN
-- 1.
SELECT "Id"
INTO v_parent_permission_id
FROM public.permissions
WHERE "Code" = 'group:tenant:finance'
ORDER BY "Id"
LIMIT 1;
IF v_parent_permission_id IS NULL THEN
v_parent_permission_id := v_permission_seed_base + 1;
INSERT INTO public.permissions (
"Id", "Name", "Code", "Description",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy",
"ParentId", "SortOrder", "Type", "Portal")
VALUES (
v_parent_permission_id, '', 'group:tenant:finance', '',
NOW(), NULL, NULL,
NULL, NULL, NULL,
0, 5000, 'group', 1)
ON CONFLICT ("Code") DO NOTHING;
END IF;
-- 2. Upsert
INSERT INTO public.permissions (
"Id", "Name", "Code", "Description",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy",
"ParentId", "SortOrder", "Type", "Portal")
VALUES (
v_permission_seed_base + 11, '', 'tenant:finance:overview:view', '',
NOW(), NULL, NULL,
NULL, NULL, NULL,
v_parent_permission_id, 5050, 'leaf', 1)
ON CONFLICT ("Code") DO UPDATE
SET "Name" = EXCLUDED."Name",
"Description" = EXCLUDED."Description",
"ParentId" = EXCLUDED."ParentId",
"SortOrder" = EXCLUDED."SortOrder",
"Type" = EXCLUDED."Type",
"Portal" = EXCLUDED."Portal",
"DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW();
SELECT "Id" INTO v_view_permission_id
FROM public.permissions
WHERE "Code" = 'tenant:finance:overview:view'
LIMIT 1;
-- 3.
SELECT "Id"
INTO v_parent_menu_id
FROM public.menu_definitions
WHERE "Portal" = 1 AND "Path" = '/finance' AND "DeletedAt" IS NULL
ORDER BY "Id"
LIMIT 1;
IF v_parent_menu_id IS NULL THEN
v_parent_menu_id := v_menu_seed_base + 1;
INSERT INTO public.menu_definitions (
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
"IsIframe", "Link", "KeepAlive", "SortOrder",
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
VALUES (
v_parent_menu_id, 0, 'Finance', '/finance', 'BasicLayout', '', 'lucide:wallet',
FALSE, NULL, FALSE, 500,
'', '', '', NULL,
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
ON CONFLICT ("Id") DO NOTHING;
END IF;
-- 4. Upsert
SELECT "Id"
INTO v_overview_menu_id
FROM public.menu_definitions
WHERE "Portal" = 1
AND ("Path" = '/finance/overview' OR ("Path" = 'overview' AND "Component" = '/finance/overview/index'))
ORDER BY "DeletedAt" NULLS FIRST, "Id"
LIMIT 1;
IF v_overview_menu_id IS NULL THEN
v_overview_menu_id := v_menu_seed_base + 11;
INSERT INTO public.menu_definitions (
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
"IsIframe", "Link", "KeepAlive", "SortOrder",
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
VALUES (
v_overview_menu_id, v_parent_menu_id, 'FinanceOverview', '/finance/overview', '/finance/overview/index', '', 'lucide:layout-dashboard',
FALSE, NULL, TRUE, 505,
'tenant:finance:overview:view', 'tenant:finance:overview:view', '', NULL,
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
ON CONFLICT ("Id") DO NOTHING;
ELSE
UPDATE public.menu_definitions
SET "ParentId" = v_parent_menu_id,
"Name" = 'FinanceOverview',
"Path" = '/finance/overview',
"Component" = '/finance/overview/index',
"Title" = '',
"Icon" = 'lucide:layout-dashboard',
"IsIframe" = FALSE,
"Link" = NULL,
"KeepAlive" = TRUE,
"SortOrder" = 505,
"RequiredPermissions" = 'tenant:finance:overview:view',
"MetaPermissions" = 'tenant:finance:overview:view',
"MetaRoles" = '',
"DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW(),
"Portal" = 1
WHERE "Id" = v_overview_menu_id;
END IF;
-- 5. tenant-admin
INSERT INTO public.role_permissions (
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
SELECT
ABS(HASHTEXTEXTENDED('tenant-admin:overview:' || role."Id"::text || ':' || v_view_permission_id::text, 0)),
role."Id",
v_view_permission_id,
NOW(), NULL, NULL,
NULL, NULL, NULL,
role."TenantId",
1
FROM public.roles role
WHERE role."Code" = 'tenant-admin'
AND role."DeletedAt" IS NULL
AND v_view_permission_id IS NOT NULL
ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
SET "DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW(),
"Portal" = 1;
-- 6. tenant-admin
INSERT INTO public.role_template_permissions (
"Id", "RoleTemplateId", "PermissionCode",
"CreatedAt", "UpdatedAt", "DeletedAt",
"CreatedBy", "UpdatedBy", "DeletedBy")
SELECT
ABS(HASHTEXTEXTENDED('template-overview:' || template."Id"::text || ':tenant:finance:overview:view', 0)),
template."Id",
'tenant:finance:overview:view',
NOW(), NULL, NULL,
NULL, NULL, NULL
FROM public.role_templates template
WHERE template."TemplateCode" = 'tenant-admin'
AND template."DeletedAt" IS NULL
ON CONFLICT ("RoleTemplateId", "PermissionCode") DO UPDATE
SET "DeletedAt" = NULL,
"DeletedBy" = NULL,
"UpdatedAt" = NOW();
END $$;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DO $$
BEGIN
DELETE FROM public.role_permissions
WHERE "PermissionId" IN (
SELECT "Id"
FROM public.permissions
WHERE "Code" = 'tenant:finance:overview:view');
DELETE FROM public.role_template_permissions
WHERE "PermissionCode" = 'tenant:finance:overview:view';
DELETE FROM public.menu_definitions
WHERE "Portal" = 1 AND "Path" = '/finance/overview';
DELETE FROM public.permissions
WHERE "Code" = 'tenant:finance:overview:view';
END $$;
""");
}
}

View File

@@ -7666,11 +7666,6 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("numeric(10,2)")
.HasComment("基础配送费(元)。");
b.Property<decimal>("PlatformServiceRate")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)")
.HasComment("平台服务费率(%)。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");

View File

@@ -71,7 +71,6 @@ public static class SchedulerServiceCollectionExtensions
services.AddScoped<SubscriptionExpiryCheckJob>();
services.AddScoped<SubscriptionAutoRenewalJob>();
services.AddScoped<BillingOverdueProcessJob>();
services.AddScoped<FinanceBusinessReportRefreshJob>();
return services;
}

View File

@@ -1,70 +0,0 @@
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Module.Scheduler.Jobs;
/// <summary>
/// 经营报表快照刷新任务:消费排队中的报表快照并触发生成。
/// </summary>
public sealed class FinanceBusinessReportRefreshJob(
IFinanceBusinessReportRepository financeBusinessReportRepository,
ITenantContextAccessor tenantContextAccessor,
ILogger<FinanceBusinessReportRefreshJob> logger)
{
private const int BatchSize = 80;
/// <summary>
/// 执行报表快照刷新。
/// </summary>
public async Task ExecuteAsync()
{
// 1. 拉取待处理快照任务。
var pendingList = await financeBusinessReportRepository.GetPendingSnapshotsAsync(BatchSize, CancellationToken.None);
if (pendingList.Count == 0)
{
logger.LogDebug("定时任务:经营报表快照刷新无待处理任务");
return;
}
// 2. 逐租户上下文执行生成。
var successCount = 0;
var failedCount = 0;
foreach (var pending in pendingList)
{
var previousContext = tenantContextAccessor.Current;
try
{
tenantContextAccessor.Current = new TenantContext(
pending.TenantId,
$"tenant-{pending.TenantId}",
"scheduler");
await financeBusinessReportRepository.GenerateSnapshotAsync(
pending.SnapshotId,
CancellationToken.None);
successCount += 1;
}
catch (Exception ex)
{
failedCount += 1;
logger.LogError(
ex,
"定时任务:经营报表快照刷新失败 SnapshotId={SnapshotId}, TenantId={TenantId}",
pending.SnapshotId,
pending.TenantId);
}
finally
{
tenantContextAccessor.Current = previousContext;
}
}
// 3. 记录执行结果。
logger.LogInformation(
"定时任务:经营报表快照刷新完成,处理 {TotalCount} 条,成功 {SuccessCount} 条,失败 {FailedCount} 条",
pendingList.Count,
successCount,
failedCount);
}
}

View File

@@ -45,13 +45,7 @@ public sealed class RecurringJobRegistrar(
job => job.ExecuteAsync(),
billingOptions.OverdueBillingProcessCron);
// 4. 经营报表快照刷新任务
RecurringJob.AddOrUpdate<FinanceBusinessReportRefreshJob>(
"finance.business-report-refresh",
job => job.ExecuteAsync(),
"*/10 * * * *");
// 5. (空行后) 门店管理自动化任务
// 4. (空行后) 门店管理自动化任务
RecurringJob.AddOrUpdate<BusinessStatusAutoSwitchJob>(
"stores.business-status-auto-switch",
job => job.ExecuteAsync(),