diff --git a/TakeoutSaaS.Docs b/TakeoutSaaS.Docs
index 2bceb20..6daa444 160000
--- a/TakeoutSaaS.Docs
+++ b/TakeoutSaaS.Docs
@@ -1 +1 @@
-Subproject commit 2bceb20baed29fdcc48774b6b65fb9121e806b6f
+Subproject commit 6daa444c5efd2106daab7cbfd82e14b650a36ab7
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceBusinessReportContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceBusinessReportContracts.cs
new file mode 100644
index 0000000..b0884b6
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceBusinessReportContracts.cs
@@ -0,0 +1,285 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Finance;
+
+///
+/// 经营报表列表请求。
+///
+public sealed class FinanceBusinessReportListRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 周期类型(daily/weekly/monthly)。
+ ///
+ public string? PeriodType { get; set; } = "daily";
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 20;
+}
+
+///
+/// 经营报表详情请求。
+///
+public sealed class FinanceBusinessReportDetailRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 报表 ID。
+ ///
+ public string ReportId { get; set; } = string.Empty;
+}
+
+///
+/// 经营报表批量导出请求。
+///
+public sealed class FinanceBusinessReportBatchExportRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 周期类型(daily/weekly/monthly)。
+ ///
+ public string? PeriodType { get; set; } = "daily";
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 20;
+}
+
+///
+/// 经营报表列表行响应。
+///
+public sealed class FinanceBusinessReportListItemResponse
+{
+ ///
+ /// 报表 ID。
+ ///
+ public string ReportId { get; set; } = string.Empty;
+
+ ///
+ /// 日期文案。
+ ///
+ public string DateText { get; set; } = string.Empty;
+
+ ///
+ /// 营业额。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 订单数。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageOrderValue { get; set; }
+
+ ///
+ /// 退款率(百分数)。
+ ///
+ public decimal RefundRatePercent { get; set; }
+
+ ///
+ /// 成本总额。
+ ///
+ public decimal CostTotalAmount { get; set; }
+
+ ///
+ /// 净利润。
+ ///
+ public decimal NetProfitAmount { get; set; }
+
+ ///
+ /// 利润率(百分数)。
+ ///
+ public decimal ProfitRatePercent { get; set; }
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// 是否可下载。
+ ///
+ public bool CanDownload { get; set; }
+}
+
+///
+/// 经营报表列表响应。
+///
+public sealed class FinanceBusinessReportListResultResponse
+{
+ ///
+ /// 列表。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 总数。
+ ///
+ public int Total { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+}
+
+///
+/// KPI 响应项。
+///
+public sealed class FinanceBusinessReportKpiResponse
+{
+ ///
+ /// 指标键。
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 指标名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 指标值文案。
+ ///
+ public string ValueText { get; set; } = string.Empty;
+
+ ///
+ /// 同比变化率(百分数)。
+ ///
+ public decimal YoyChangeRate { get; set; }
+
+ ///
+ /// 环比变化率(百分数)。
+ ///
+ public decimal MomChangeRate { get; set; }
+}
+
+///
+/// 明细行响应项。
+///
+public sealed class FinanceBusinessReportBreakdownItemResponse
+{
+ ///
+ /// 明细键。
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 明细名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 占比(百分数)。
+ ///
+ public decimal RatioPercent { get; set; }
+}
+
+///
+/// 经营报表详情响应。
+///
+public sealed class FinanceBusinessReportDetailResponse
+{
+ ///
+ /// 报表 ID。
+ ///
+ public string ReportId { get; set; } = string.Empty;
+
+ ///
+ /// 标题。
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// 周期类型编码。
+ ///
+ public string PeriodType { get; set; } = string.Empty;
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// KPI 列表。
+ ///
+ public List Kpis { get; set; } = [];
+
+ ///
+ /// 收入明细(按渠道)。
+ ///
+ public List IncomeBreakdowns { get; set; } = [];
+
+ ///
+ /// 成本明细(按类别)。
+ ///
+ public List CostBreakdowns { get; set; } = [];
+}
+
+///
+/// 经营报表导出响应。
+///
+public sealed class FinanceBusinessReportExportResponse
+{
+ ///
+ /// 文件名。
+ ///
+ public string FileName { get; set; } = string.Empty;
+
+ ///
+ /// Base64 文件内容。
+ ///
+ public string FileContentBase64 { get; set; } = string.Empty;
+
+ ///
+ /// 导出总记录数。
+ ///
+ public int TotalCount { get; set; }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceReportController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceReportController.cs
new file mode 100644
index 0000000..a2cfc81
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceReportController.cs
@@ -0,0 +1,250 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+using TakeoutSaaS.Application.App.Stores.Services;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Module.Authorization.Attributes;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+using TakeoutSaaS.TenantApi.Contracts.Finance;
+
+namespace TakeoutSaaS.TenantApi.Controllers;
+
+///
+/// 财务中心经营报表。
+///
+[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";
+
+ ///
+ /// 查询经营报表列表。
+ ///
+ [HttpGet("list")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.Ok(new FinanceBusinessReportListResultResponse
+ {
+ Items = result.Items.Select(MapListItem).ToList(),
+ Total = result.Total,
+ Page = result.Page,
+ PageSize = result.PageSize
+ });
+ }
+
+ ///
+ /// 查询经营报表详情。
+ ///
+ [HttpGet("detail")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.Error(ErrorCodes.NotFound, "经营报表不存在");
+ }
+
+ return ApiResponse.Ok(MapDetail(detail));
+ }
+
+ ///
+ /// 导出单条报表 PDF。
+ ///
+ [HttpGet("export/pdf")]
+ [PermissionAuthorize(ExportPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.Ok(MapExport(export));
+ }
+
+ ///
+ /// 导出单条报表 Excel。
+ ///
+ [HttpGet("export/excel")]
+ [PermissionAuthorize(ExportPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.Ok(MapExport(export));
+ }
+
+ ///
+ /// 批量导出报表 ZIP(PDF + Excel)。
+ ///
+ [HttpGet("export/batch")]
+ [PermissionAuthorize(ExportPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> 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.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
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Dto/FinanceBusinessReportDtos.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Dto/FinanceBusinessReportDtos.cs
new file mode 100644
index 0000000..bcf431c
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Dto/FinanceBusinessReportDtos.cs
@@ -0,0 +1,218 @@
+namespace TakeoutSaaS.Application.App.Finance.Reports.Dto;
+
+///
+/// 经营报表列表行 DTO。
+///
+public sealed class FinanceBusinessReportListItemDto
+{
+ ///
+ /// 报表 ID。
+ ///
+ public string ReportId { get; set; } = string.Empty;
+
+ ///
+ /// 日期文案。
+ ///
+ public string DateText { get; set; } = string.Empty;
+
+ ///
+ /// 营业额。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 订单数。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageOrderValue { get; set; }
+
+ ///
+ /// 退款率(百分数)。
+ ///
+ public decimal RefundRatePercent { get; set; }
+
+ ///
+ /// 成本总额。
+ ///
+ public decimal CostTotalAmount { get; set; }
+
+ ///
+ /// 净利润。
+ ///
+ public decimal NetProfitAmount { get; set; }
+
+ ///
+ /// 利润率(百分数)。
+ ///
+ public decimal ProfitRatePercent { get; set; }
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// 是否可下载。
+ ///
+ public bool CanDownload { get; set; }
+}
+
+///
+/// 经营报表列表结果 DTO。
+///
+public sealed class FinanceBusinessReportListResultDto
+{
+ ///
+ /// 列表项。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 总数。
+ ///
+ public int Total { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+}
+
+///
+/// 经营报表 KPI DTO。
+///
+public sealed class FinanceBusinessReportKpiDto
+{
+ ///
+ /// 指标键。
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 指标名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 指标值文本。
+ ///
+ public string ValueText { get; set; } = string.Empty;
+
+ ///
+ /// 同比变化率(百分数)。
+ ///
+ public decimal YoyChangeRate { get; set; }
+
+ ///
+ /// 环比变化率(百分数)。
+ ///
+ public decimal MomChangeRate { get; set; }
+}
+
+///
+/// 经营报表明细行 DTO。
+///
+public sealed class FinanceBusinessReportBreakdownItemDto
+{
+ ///
+ /// 明细键。
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 明细名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 占比(百分数)。
+ ///
+ public decimal RatioPercent { get; set; }
+}
+
+///
+/// 经营报表详情 DTO。
+///
+public sealed class FinanceBusinessReportDetailDto
+{
+ ///
+ /// 报表 ID。
+ ///
+ public string ReportId { get; set; } = string.Empty;
+
+ ///
+ /// 标题。
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// 周期类型编码。
+ ///
+ public string PeriodType { get; set; } = string.Empty;
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// 关键指标。
+ ///
+ public List Kpis { get; set; } = [];
+
+ ///
+ /// 收入明细(按渠道)。
+ ///
+ public List IncomeBreakdowns { get; set; } = [];
+
+ ///
+ /// 成本明细(按类别)。
+ ///
+ public List CostBreakdowns { get; set; } = [];
+}
+
+///
+/// 经营报表导出 DTO。
+///
+public sealed class FinanceBusinessReportExportDto
+{
+ ///
+ /// 文件名。
+ ///
+ public string FileName { get; set; } = string.Empty;
+
+ ///
+ /// Base64 文件内容。
+ ///
+ public string FileContentBase64 { get; set; } = string.Empty;
+
+ ///
+ /// 总记录数。
+ ///
+ public int TotalCount { get; set; }
+}
+
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportBatchQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportBatchQueryHandler.cs
new file mode 100644
index 0000000..1101089
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportBatchQueryHandler.cs
@@ -0,0 +1,112 @@
+using System.Globalization;
+using System.IO.Compression;
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Domain.Finance.Services;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
+
+///
+/// 经营报表批量导出处理器(ZIP:PDF + Excel)。
+///
+public sealed class ExportFinanceBusinessReportBatchQueryHandler(
+ IFinanceBusinessReportRepository financeBusinessReportRepository,
+ IFinanceBusinessReportExportService financeBusinessReportExportService,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task 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 CreateZipAsync(
+ IReadOnlyList 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();
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportExcelQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportExcelQueryHandler.cs
new file mode 100644
index 0000000..8f9fc64
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportExcelQueryHandler.cs
@@ -0,0 +1,59 @@
+using System.Globalization;
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Domain.Finance.Services;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
+
+///
+/// 经营报表 Excel 导出处理器。
+///
+public sealed class ExportFinanceBusinessReportExcelQueryHandler(
+ IFinanceBusinessReportRepository financeBusinessReportRepository,
+ IFinanceBusinessReportExportService financeBusinessReportExportService,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task 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
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportPdfQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportPdfQueryHandler.cs
new file mode 100644
index 0000000..2d5eb3c
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportPdfQueryHandler.cs
@@ -0,0 +1,59 @@
+using System.Globalization;
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Domain.Finance.Services;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
+
+///
+/// 经营报表 PDF 导出处理器。
+///
+public sealed class ExportFinanceBusinessReportPdfQueryHandler(
+ IFinanceBusinessReportRepository financeBusinessReportRepository,
+ IFinanceBusinessReportExportService financeBusinessReportExportService,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task 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
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/FinanceBusinessReportMapping.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/FinanceBusinessReportMapping.cs
new file mode 100644
index 0000000..a8a6a58
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/FinanceBusinessReportMapping.cs
@@ -0,0 +1,178 @@
+using System.Globalization;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
+
+///
+/// 经营报表映射工具。
+///
+internal static class FinanceBusinessReportMapping
+{
+ ///
+ /// 映射列表行 DTO。
+ ///
+ 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
+ };
+ }
+
+ ///
+ /// 映射详情 DTO。
+ ///
+ 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()
+ };
+ }
+
+ ///
+ /// 周期类型编码。
+ ///
+ 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);
+ }
+}
+
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/GetFinanceBusinessReportDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/GetFinanceBusinessReportDetailQueryHandler.cs
new file mode 100644
index 0000000..d3f7b09
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/GetFinanceBusinessReportDetailQueryHandler.cs
@@ -0,0 +1,39 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
+
+///
+/// 经营报表详情查询处理器。
+///
+public sealed class GetFinanceBusinessReportDetailQueryHandler(
+ IFinanceBusinessReportRepository financeBusinessReportRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task 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);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/SearchFinanceBusinessReportListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/SearchFinanceBusinessReportListQueryHandler.cs
new file mode 100644
index 0000000..356d3a2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/SearchFinanceBusinessReportListQueryHandler.cs
@@ -0,0 +1,57 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
+
+///
+/// 经营报表分页查询处理器。
+///
+public sealed class SearchFinanceBusinessReportListQueryHandler(
+ IFinanceBusinessReportRepository financeBusinessReportRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task 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
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportBatchQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportBatchQuery.cs
new file mode 100644
index 0000000..94661bf
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportBatchQuery.cs
@@ -0,0 +1,31 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+///
+/// 批量导出经营报表(ZIP:PDF + Excel)。
+///
+public sealed class ExportFinanceBusinessReportBatchQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 周期类型。
+ ///
+ public FinanceBusinessReportPeriodType PeriodType { get; init; } = FinanceBusinessReportPeriodType.Daily;
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; init; } = 20;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportExcelQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportExcelQuery.cs
new file mode 100644
index 0000000..eae96b7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportExcelQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+///
+/// 导出经营报表 Excel。
+///
+public sealed class ExportFinanceBusinessReportExcelQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 报表 ID。
+ ///
+ public long ReportId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportPdfQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportPdfQuery.cs
new file mode 100644
index 0000000..ae1a6ee
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportPdfQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+///
+/// 导出经营报表 PDF。
+///
+public sealed class ExportFinanceBusinessReportPdfQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 报表 ID。
+ ///
+ public long ReportId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/GetFinanceBusinessReportDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/GetFinanceBusinessReportDetailQuery.cs
new file mode 100644
index 0000000..b6da647
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/GetFinanceBusinessReportDetailQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+///
+/// 查询经营报表详情。
+///
+public sealed class GetFinanceBusinessReportDetailQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 报表 ID。
+ ///
+ public long ReportId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/SearchFinanceBusinessReportListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/SearchFinanceBusinessReportListQuery.cs
new file mode 100644
index 0000000..4d584d9
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/SearchFinanceBusinessReportListQuery.cs
@@ -0,0 +1,31 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+///
+/// 查询经营报表分页列表。
+///
+public sealed class SearchFinanceBusinessReportListQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 周期类型。
+ ///
+ public FinanceBusinessReportPeriodType PeriodType { get; init; } = FinanceBusinessReportPeriodType.Daily;
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; init; } = 20;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportBatchQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportBatchQueryValidator.cs
new file mode 100644
index 0000000..d49aa5b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportBatchQueryValidator.cs
@@ -0,0 +1,20 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
+
+///
+/// 经营报表批量导出查询验证器。
+///
+public sealed class ExportFinanceBusinessReportBatchQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public ExportFinanceBusinessReportBatchQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.Page).GreaterThan(0);
+ RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportExcelQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportExcelQueryValidator.cs
new file mode 100644
index 0000000..2c6026a
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportExcelQueryValidator.cs
@@ -0,0 +1,19 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
+
+///
+/// 经营报表 Excel 导出查询验证器。
+///
+public sealed class ExportFinanceBusinessReportExcelQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public ExportFinanceBusinessReportExcelQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.ReportId).GreaterThan(0);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportPdfQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportPdfQueryValidator.cs
new file mode 100644
index 0000000..c17e111
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportPdfQueryValidator.cs
@@ -0,0 +1,19 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
+
+///
+/// 经营报表 PDF 导出查询验证器。
+///
+public sealed class ExportFinanceBusinessReportPdfQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public ExportFinanceBusinessReportPdfQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.ReportId).GreaterThan(0);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/GetFinanceBusinessReportDetailQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/GetFinanceBusinessReportDetailQueryValidator.cs
new file mode 100644
index 0000000..b5a59c7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/GetFinanceBusinessReportDetailQueryValidator.cs
@@ -0,0 +1,19 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
+
+///
+/// 经营报表详情查询验证器。
+///
+public sealed class GetFinanceBusinessReportDetailQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public GetFinanceBusinessReportDetailQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.ReportId).GreaterThan(0);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/SearchFinanceBusinessReportListQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/SearchFinanceBusinessReportListQueryValidator.cs
new file mode 100644
index 0000000..2894a73
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/SearchFinanceBusinessReportListQueryValidator.cs
@@ -0,0 +1,20 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
+
+///
+/// 经营报表分页查询验证器。
+///
+public sealed class SearchFinanceBusinessReportListQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public SearchFinanceBusinessReportListQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.Page).GreaterThan(0);
+ RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
+ }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceBusinessReportSnapshot.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceBusinessReportSnapshot.cs
new file mode 100644
index 0000000..a4c3bc4
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceBusinessReportSnapshot.cs
@@ -0,0 +1,111 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Finance.Entities;
+
+///
+/// 经营报表快照实体。
+///
+public sealed class FinanceBusinessReportSnapshot : MultiTenantEntityBase
+{
+ ///
+ /// 所属门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 周期类型。
+ ///
+ public FinanceBusinessReportPeriodType PeriodType { get; set; }
+
+ ///
+ /// 周期开始时间(UTC,含)。
+ ///
+ public DateTime PeriodStartAt { get; set; }
+
+ ///
+ /// 周期结束时间(UTC,不含)。
+ ///
+ public DateTime PeriodEndAt { get; set; }
+
+ ///
+ /// 生成状态。
+ ///
+ public FinanceBusinessReportStatus Status { get; set; } = FinanceBusinessReportStatus.Queued;
+
+ ///
+ /// 营业额。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 订单数。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageOrderValue { get; set; }
+
+ ///
+ /// 退款率(0-1)。
+ ///
+ public decimal RefundRate { get; set; }
+
+ ///
+ /// 成本总额。
+ ///
+ public decimal CostTotalAmount { get; set; }
+
+ ///
+ /// 净利润。
+ ///
+ public decimal NetProfitAmount { get; set; }
+
+ ///
+ /// 利润率(0-1)。
+ ///
+ public decimal ProfitRate { get; set; }
+
+ ///
+ /// KPI 比较快照 JSON(同比/环比)。
+ ///
+ public string KpiComparisonJson { get; set; } = "[]";
+
+ ///
+ /// 收入明细快照 JSON(按渠道)。
+ ///
+ public string IncomeBreakdownJson { get; set; } = "[]";
+
+ ///
+ /// 成本明细快照 JSON(按类别)。
+ ///
+ public string CostBreakdownJson { get; set; } = "[]";
+
+ ///
+ /// 生成开始时间(UTC)。
+ ///
+ public DateTime? StartedAt { get; set; }
+
+ ///
+ /// 生成完成时间(UTC)。
+ ///
+ public DateTime? FinishedAt { get; set; }
+
+ ///
+ /// 最近一次失败信息。
+ ///
+ public string? LastError { get; set; }
+
+ ///
+ /// 重试次数。
+ ///
+ public int RetryCount { get; set; }
+
+ ///
+ /// 调度任务 ID。
+ ///
+ public string? HangfireJobId { get; set; }
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostDailyOverride.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostDailyOverride.cs
new file mode 100644
index 0000000..afae73b
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostDailyOverride.cs
@@ -0,0 +1,36 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Finance.Entities;
+
+///
+/// 成本日覆盖实体。
+///
+public sealed class FinanceCostDailyOverride : MultiTenantEntityBase
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 业务日期(UTC 日期)。
+ ///
+ public DateTime BusinessDate { get; set; }
+
+ ///
+ /// 成本分类。
+ ///
+ public FinanceCostCategory Category { get; set; }
+
+ ///
+ /// 覆盖金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 备注。
+ ///
+ public string? Remark { get; set; }
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostProfile.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostProfile.cs
new file mode 100644
index 0000000..52419e8
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostProfile.cs
@@ -0,0 +1,56 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Finance.Entities;
+
+///
+/// 成本配置实体(类别级规则)。
+///
+public sealed class FinanceCostProfile : MultiTenantEntityBase
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 成本分类。
+ ///
+ public FinanceCostCategory Category { get; set; }
+
+ ///
+ /// 计算模式。
+ ///
+ public FinanceCostCalcMode CalcMode { get; set; }
+
+ ///
+ /// 比例值(0-1,Ratio 模式使用)。
+ ///
+ public decimal Ratio { get; set; }
+
+ ///
+ /// 固定日金额(FixedDaily 模式使用)。
+ ///
+ public decimal FixedDailyAmount { get; set; }
+
+ ///
+ /// 生效开始日期(UTC 日期)。
+ ///
+ public DateTime EffectiveFrom { get; set; }
+
+ ///
+ /// 生效结束日期(UTC 日期,含,null 表示长期)。
+ ///
+ public DateTime? EffectiveTo { get; set; }
+
+ ///
+ /// 是否启用。
+ ///
+ public bool IsEnabled { get; set; } = true;
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; set; } = 100;
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportPeriodType.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportPeriodType.cs
new file mode 100644
index 0000000..acb62a7
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportPeriodType.cs
@@ -0,0 +1,23 @@
+namespace TakeoutSaaS.Domain.Finance.Enums;
+
+///
+/// 经营报表周期类型。
+///
+public enum FinanceBusinessReportPeriodType
+{
+ ///
+ /// 日报。
+ ///
+ Daily = 1,
+
+ ///
+ /// 周报。
+ ///
+ Weekly = 2,
+
+ ///
+ /// 月报。
+ ///
+ Monthly = 3
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportStatus.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportStatus.cs
new file mode 100644
index 0000000..71e0b09
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportStatus.cs
@@ -0,0 +1,28 @@
+namespace TakeoutSaaS.Domain.Finance.Enums;
+
+///
+/// 经营报表快照状态。
+///
+public enum FinanceBusinessReportStatus
+{
+ ///
+ /// 已排队。
+ ///
+ Queued = 1,
+
+ ///
+ /// 生成中。
+ ///
+ Running = 2,
+
+ ///
+ /// 已生成。
+ ///
+ Succeeded = 3,
+
+ ///
+ /// 生成失败。
+ ///
+ Failed = 4
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostCalcMode.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostCalcMode.cs
new file mode 100644
index 0000000..d93f2e9
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostCalcMode.cs
@@ -0,0 +1,18 @@
+namespace TakeoutSaaS.Domain.Finance.Enums;
+
+///
+/// 成本计算模式。
+///
+public enum FinanceCostCalcMode
+{
+ ///
+ /// 按营业额比例计算。
+ ///
+ Ratio = 1,
+
+ ///
+ /// 按固定日金额计算。
+ ///
+ FixedDaily = 2
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceBusinessReportSnapshots.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceBusinessReportSnapshots.cs
new file mode 100644
index 0000000..f9eb8c1
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceBusinessReportSnapshots.cs
@@ -0,0 +1,245 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Domain.Finance.Models;
+
+///
+/// 经营报表 KPI 快照项。
+///
+public sealed class FinanceBusinessReportKpiSnapshot
+{
+ ///
+ /// 指标键。
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 指标名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 指标值。
+ ///
+ public decimal Value { get; set; }
+
+ ///
+ /// 同比变化率(百分数,如 3.5 表示 +3.5%)。
+ ///
+ public decimal YoyChangeRate { get; set; }
+
+ ///
+ /// 环比变化率(百分数,如 2.1 表示 +2.1%)。
+ ///
+ public decimal MomChangeRate { get; set; }
+}
+
+///
+/// 经营报表明细行快照。
+///
+public sealed class FinanceBusinessReportBreakdownSnapshot
+{
+ ///
+ /// 明细键。
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 明细名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 占比(0-1)。
+ ///
+ public decimal Ratio { get; set; }
+}
+
+///
+/// 经营报表列表行快照。
+///
+public sealed class FinanceBusinessReportListItemSnapshot
+{
+ ///
+ /// 报表 ID。
+ ///
+ public long ReportId { get; set; }
+
+ ///
+ /// 周期类型。
+ ///
+ public FinanceBusinessReportPeriodType PeriodType { get; set; }
+
+ ///
+ /// 周期开始时间(UTC,含)。
+ ///
+ public DateTime PeriodStartAt { get; set; }
+
+ ///
+ /// 周期结束时间(UTC,不含)。
+ ///
+ public DateTime PeriodEndAt { get; set; }
+
+ ///
+ /// 状态。
+ ///
+ public FinanceBusinessReportStatus Status { get; set; }
+
+ ///
+ /// 营业额。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 订单数。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageOrderValue { get; set; }
+
+ ///
+ /// 退款率(0-1)。
+ ///
+ public decimal RefundRate { get; set; }
+
+ ///
+ /// 成本总额。
+ ///
+ public decimal CostTotalAmount { get; set; }
+
+ ///
+ /// 净利润。
+ ///
+ public decimal NetProfitAmount { get; set; }
+
+ ///
+ /// 利润率(0-1)。
+ ///
+ public decimal ProfitRate { get; set; }
+}
+
+///
+/// 经营报表分页快照。
+///
+public sealed class FinanceBusinessReportPageSnapshot
+{
+ ///
+ /// 列表。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 总数。
+ ///
+ public int TotalCount { get; set; }
+}
+
+///
+/// 经营报表详情快照。
+///
+public sealed class FinanceBusinessReportDetailSnapshot
+{
+ ///
+ /// 报表 ID。
+ ///
+ public long ReportId { get; set; }
+
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 周期类型。
+ ///
+ public FinanceBusinessReportPeriodType PeriodType { get; set; }
+
+ ///
+ /// 周期开始时间(UTC,含)。
+ ///
+ public DateTime PeriodStartAt { get; set; }
+
+ ///
+ /// 周期结束时间(UTC,不含)。
+ ///
+ public DateTime PeriodEndAt { get; set; }
+
+ ///
+ /// 状态。
+ ///
+ public FinanceBusinessReportStatus Status { get; set; }
+
+ ///
+ /// 营业额。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 订单数。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageOrderValue { get; set; }
+
+ ///
+ /// 退款率(0-1)。
+ ///
+ public decimal RefundRate { get; set; }
+
+ ///
+ /// 成本总额。
+ ///
+ public decimal CostTotalAmount { get; set; }
+
+ ///
+ /// 净利润。
+ ///
+ public decimal NetProfitAmount { get; set; }
+
+ ///
+ /// 利润率(0-1)。
+ ///
+ public decimal ProfitRate { get; set; }
+
+ ///
+ /// 关键指标快照列表。
+ ///
+ public List Kpis { get; set; } = [];
+
+ ///
+ /// 收入明细(按渠道)。
+ ///
+ public List IncomeBreakdowns { get; set; } = [];
+
+ ///
+ /// 成本明细(按类别)。
+ ///
+ public List CostBreakdowns { get; set; } = [];
+}
+
+///
+/// 待处理报表任务快照。
+///
+public sealed class FinanceBusinessReportPendingSnapshot
+{
+ ///
+ /// 快照 ID。
+ ///
+ public long SnapshotId { get; set; }
+
+ ///
+ /// 租户 ID。
+ ///
+ public long TenantId { get; set; }
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceBusinessReportRepository.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceBusinessReportRepository.cs
new file mode 100644
index 0000000..aed9aee
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceBusinessReportRepository.cs
@@ -0,0 +1,77 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+
+namespace TakeoutSaaS.Domain.Finance.Repositories;
+
+///
+/// 经营报表仓储契约。
+///
+public interface IFinanceBusinessReportRepository
+{
+ ///
+ /// 确保门店存在默认成本配置。
+ ///
+ Task EnsureDefaultCostProfilesAsync(
+ long tenantId,
+ long storeId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 为指定分页周期补齐快照并排队。
+ ///
+ Task QueueSnapshotsForPageAsync(
+ long tenantId,
+ long storeId,
+ FinanceBusinessReportPeriodType periodType,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询经营报表分页结果。
+ ///
+ Task SearchPageAsync(
+ long tenantId,
+ long storeId,
+ FinanceBusinessReportPeriodType periodType,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询经营报表详情。
+ ///
+ Task GetDetailAsync(
+ long tenantId,
+ long storeId,
+ long reportId,
+ bool allowRealtimeBuild,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询批量导出详情集合。
+ ///
+ Task> ListBatchDetailsAsync(
+ long tenantId,
+ long storeId,
+ FinanceBusinessReportPeriodType periodType,
+ int page,
+ int pageSize,
+ bool allowRealtimeBuild,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 拉取待处理任务。
+ ///
+ Task> GetPendingSnapshotsAsync(
+ int take,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 执行报表快照生成。
+ ///
+ Task GenerateSnapshotAsync(
+ long snapshotId,
+ CancellationToken cancellationToken = default);
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Services/IFinanceBusinessReportExportService.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Services/IFinanceBusinessReportExportService.cs
new file mode 100644
index 0000000..9d2f212
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Services/IFinanceBusinessReportExportService.cs
@@ -0,0 +1,38 @@
+using TakeoutSaaS.Domain.Finance.Models;
+
+namespace TakeoutSaaS.Domain.Finance.Services;
+
+///
+/// 经营报表导出服务契约。
+///
+public interface IFinanceBusinessReportExportService
+{
+ ///
+ /// 导出单条报表 PDF。
+ ///
+ Task ExportSinglePdfAsync(
+ FinanceBusinessReportDetailSnapshot detail,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 导出单条报表 Excel。
+ ///
+ Task ExportSingleExcelAsync(
+ FinanceBusinessReportDetailSnapshot detail,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 导出批量报表 PDF。
+ ///
+ Task ExportBatchPdfAsync(
+ IReadOnlyList details,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 导出批量报表 Excel。
+ ///
+ Task ExportBatchExcelAsync(
+ IReadOnlyList details,
+ CancellationToken cancellationToken = default);
+}
+
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
index cf47802..60b714b 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
@@ -2,6 +2,7 @@ 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;
@@ -56,6 +57,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -80,6 +82,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
// 2. (空行后) 门店配置服务
services.AddScoped();
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
index 759b770..a7d50b6 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
@@ -103,6 +103,18 @@ public sealed class TakeoutAppDbContext(
///
public DbSet TenantInvoiceRecords => Set();
///
+ /// 经营报表快照。
+ ///
+ public DbSet FinanceBusinessReportSnapshots => Set();
+ ///
+ /// 成本配置。
+ ///
+ public DbSet FinanceCostProfiles => Set();
+ ///
+ /// 成本日覆盖。
+ ///
+ public DbSet FinanceCostDailyOverrides => Set();
+ ///
/// 成本录入汇总。
///
public DbSet FinanceCostEntries => Set();
@@ -544,6 +556,9 @@ public sealed class TakeoutAppDbContext(
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity());
ConfigureTenantInvoiceSetting(modelBuilder.Entity());
ConfigureTenantInvoiceRecord(modelBuilder.Entity());
+ ConfigureFinanceBusinessReportSnapshot(modelBuilder.Entity());
+ ConfigureFinanceCostProfile(modelBuilder.Entity());
+ ConfigureFinanceCostDailyOverride(modelBuilder.Entity());
ConfigureFinanceCostEntry(modelBuilder.Entity());
ConfigureFinanceCostEntryItem(modelBuilder.Entity());
ConfigureQuotaPackage(modelBuilder.Entity());
@@ -1109,6 +1124,68 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.InvoiceType, x.AppliedAt });
}
+ private static void ConfigureFinanceBusinessReportSnapshot(EntityTypeBuilder 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().IsRequired();
+ builder.Property(x => x.PeriodStartAt).IsRequired();
+ builder.Property(x => x.PeriodEndAt).IsRequired();
+ builder.Property(x => x.Status).HasConversion().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 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().IsRequired();
+ builder.Property(x => x.CalcMode).HasConversion().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 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().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 builder)
{
builder.ToTable("finance_cost_entries");
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceBusinessReportRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceBusinessReportRepository.cs
new file mode 100644
index 0000000..5e8c00b
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceBusinessReportRepository.cs
@@ -0,0 +1,761 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Finance.Entities;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Domain.Orders.Enums;
+using TakeoutSaaS.Domain.Payments.Enums;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Infrastructure.App.Repositories;
+
+///
+/// 经营报表 EF Core 仓储实现。
+///
+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 DefaultCostProfileMap =
+ new Dictionary
+ {
+ [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)
+ };
+
+ ///
+ 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);
+ }
+
+ ///
+ 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);
+ }
+ }
+
+ ///
+ public async Task 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
+ };
+ }
+
+ ///
+ public async Task 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(snapshot.KpiComparisonJson),
+ IncomeBreakdowns = Deserialize(snapshot.IncomeBreakdownJson),
+ CostBreakdowns = Deserialize(snapshot.CostBreakdownJson)
+ };
+ }
+
+ ///
+ public async Task> 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(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;
+ }
+
+ ///
+ public async Task> 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();
+ }
+
+ ///
+ 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> 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 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 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> 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();
+ 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> 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> BuildCostBreakdownsAsync(
+ long tenantId,
+ long storeId,
+ DateTime startAt,
+ DateTime endAt,
+ IReadOnlyDictionary 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 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 Deserialize(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return [];
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize>(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 Kpis { get; init; } = [];
+
+ public List IncomeBreakdowns { get; init; } = [];
+
+ public List 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));
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/FinanceBusinessReportExportService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/FinanceBusinessReportExportService.cs
new file mode 100644
index 0000000..8e42e81
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/FinanceBusinessReportExportService.cs
@@ -0,0 +1,303 @@
+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;
+
+///
+/// 经营报表导出服务实现(PDF / Excel)。
+///
+public sealed class FinanceBusinessReportExportService : IFinanceBusinessReportExportService
+{
+ public FinanceBusinessReportExportService()
+ {
+ QuestPDF.Settings.License = LicenseType.Community;
+ }
+
+ ///
+ public Task ExportSinglePdfAsync(
+ FinanceBusinessReportDetailSnapshot detail,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(detail);
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(BuildPdf([detail]));
+ }
+
+ ///
+ public Task 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());
+ }
+
+ ///
+ public Task ExportBatchPdfAsync(
+ IReadOnlyList details,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(details);
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(BuildPdf(details));
+ }
+
+ ///
+ public Task ExportBatchExcelAsync(
+ IReadOnlyList 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 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 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 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 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 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);
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304071623_AddFinanceInvoiceModule.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304071623_AddFinanceInvoiceModule.Designer.cs
new file mode 100644
index 0000000..e64a535
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304071623_AddFinanceInvoiceModule.Designer.cs
@@ -0,0 +1,10972 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+#nullable disable
+
+namespace TakeoutSaaS.Infrastructure.Migrations
+{
+ [DbContext(typeof(TakeoutAppDbContext))]
+ [Migration("20260304071623_AddFinanceInvoiceModule")]
+ partial class AddFinanceInvoiceModule
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Consumed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConsumerId")
+ .HasColumnType("uuid");
+
+ b.Property("Delivered")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSequenceNumber")
+ .HasColumnType("bigint");
+
+ b.Property("LockId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("ReceiveCount")
+ .HasColumnType("integer");
+
+ b.Property("Received")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("bytea");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Delivered");
+
+ b.ToTable("InboxState");
+ });
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
+ {
+ b.Property("SequenceNumber")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber"));
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("ConversationId")
+ .HasColumnType("uuid");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uuid");
+
+ b.Property("DestinationAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EnqueueTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FaultAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Headers")
+ .HasColumnType("text");
+
+ b.Property("InboxConsumerId")
+ .HasColumnType("uuid");
+
+ b.Property("InboxMessageId")
+ .HasColumnType("uuid");
+
+ b.Property("InitiatorId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("OutboxId")
+ .HasColumnType("uuid");
+
+ b.Property("Properties")
+ .HasColumnType("text");
+
+ b.Property("RequestId")
+ .HasColumnType("uuid");
+
+ b.Property("ResponseAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("SentTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SourceAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("SequenceNumber");
+
+ b.HasIndex("EnqueueTime");
+
+ b.HasIndex("ExpirationTime");
+
+ b.HasIndex("OutboxId", "SequenceNumber")
+ .IsUnique();
+
+ b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
+ .IsUnique();
+
+ b.ToTable("OutboxMessage");
+ });
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
+ {
+ b.Property("OutboxId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Delivered")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSequenceNumber")
+ .HasColumnType("bigint");
+
+ b.Property("LockId")
+ .HasColumnType("uuid");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("bytea");
+
+ b.HasKey("OutboxId");
+
+ b.HasIndex("Created");
+
+ b.ToTable("OutboxState");
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricAlertRule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionJson")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasComment("触发条件 JSON。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean")
+ .HasComment("是否启用。");
+
+ b.Property("MetricDefinitionId")
+ .HasColumnType("bigint")
+ .HasComment("关联指标。");
+
+ b.Property("NotificationChannels")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("通知渠道。");
+
+ b.Property("Severity")
+ .HasColumnType("integer")
+ .HasComment("告警级别。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "MetricDefinitionId", "Severity");
+
+ b.ToTable("metric_alert_rules", null, t =>
+ {
+ t.HasComment("指标告警规则。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasComment("指标编码。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DefaultAggregation")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("默认聚合方式。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Description")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("说明。");
+
+ b.Property("DimensionsJson")
+ .HasColumnType("text")
+ .HasComment("维度描述 JSON。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("指标名称。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique();
+
+ b.ToTable("metric_definitions", null, t =>
+ {
+ t.HasComment("指标定义,描述可观测的数据点。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricSnapshot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("DimensionKey")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("维度键(JSON)。");
+
+ b.Property("MetricDefinitionId")
+ .HasColumnType("bigint")
+ .HasComment("指标定义 ID。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("Value")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)")
+ .HasComment("数值。");
+
+ b.Property("WindowEnd")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("统计时间窗口结束。");
+
+ b.Property("WindowStart")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("统计时间窗口开始。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "MetricDefinitionId", "DimensionKey", "WindowStart", "WindowEnd")
+ .IsUnique();
+
+ b.ToTable("metric_snapshots", null, t =>
+ {
+ t.HasComment("指标快照,用于大盘展示。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.Coupon", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasComment("券码或序列号。");
+
+ b.Property("CouponTemplateId")
+ .HasColumnType("bigint")
+ .HasComment("模板标识。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("ExpireAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("到期时间。");
+
+ b.Property("IssuedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("发放时间。");
+
+ b.Property("OrderId")
+ .HasColumnType("bigint")
+ .HasComment("订单 ID(已使用时记录)。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("最近一次更新时间(UTC),从未更新时为 null。");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("bigint")
+ .HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("使用时间。");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasComment("归属用户。");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique();
+
+ b.ToTable("coupons", null, t =>
+ {
+ t.HasComment("用户领取的券。");
+ });
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.CouponTemplate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AllowStack")
+ .HasColumnType("boolean")
+ .HasComment("是否允许叠加其他优惠。");
+
+ b.Property("ChannelsJson")
+ .HasColumnType("text")
+ .HasComment("发放渠道(JSON)。");
+
+ b.Property("ClaimedQuantity")
+ .HasColumnType("integer")
+ .HasComment("已领取数量。");
+
+ b.Property("CouponType")
+ .HasColumnType("integer")
+ .HasComment("券类型。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Description")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasComment("备注。");
+
+ b.Property("DiscountCap")
+ .HasColumnType("numeric")
+ .HasComment("折扣上限(针对折扣券)。");
+
+ b.Property("MinimumSpend")
+ .HasColumnType("numeric")
+ .HasComment("最低消费门槛。");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("模板名称。");
+
+ b.Property("PerUserLimit")
+ .HasColumnType("integer")
+ .HasComment("每位用户可领取上限。");
+
+ b.Property("ProductScopeJson")
+ .HasColumnType("text")
+ .HasComment("适用品类或商品范围(JSON)。");
+
+ b.Property("RelativeValidDays")
+ .HasColumnType("integer")
+ .HasComment("有效天数(相对发放时间)。");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasComment("状态。");
+
+ b.Property("StoreScopeJson")
+ .HasColumnType("text")
+ .HasComment("适用门店 ID 集合(JSON)。");
+
+ b.Property