merge: bring finance report api changes into dev
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 26s
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 26s
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表列表请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型(daily/weekly/monthly)。
|
||||
/// </summary>
|
||||
public string? PeriodType { get; set; } = "daily";
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表详情请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public string ReportId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表批量导出请求。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportBatchExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型(daily/weekly/monthly)。
|
||||
/// </summary>
|
||||
public string? PeriodType { get; set; } = "daily";
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表列表行响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public string ReportId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 日期文案。
|
||||
/// </summary>
|
||||
public string DateText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 营业额。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageOrderValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款率(百分数)。
|
||||
/// </summary>
|
||||
public decimal RefundRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本总额。
|
||||
/// </summary>
|
||||
public decimal CostTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 净利润。
|
||||
/// </summary>
|
||||
public decimal NetProfitAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 利润率(百分数)。
|
||||
/// </summary>
|
||||
public decimal ProfitRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否可下载。
|
||||
/// </summary>
|
||||
public bool CanDownload { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表列表响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportListItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总数。
|
||||
/// </summary>
|
||||
public int Total { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// KPI 响应项。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportKpiResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 指标键。
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 指标名称。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 指标值文案。
|
||||
/// </summary>
|
||||
public string ValueText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 同比变化率(百分数)。
|
||||
/// </summary>
|
||||
public decimal YoyChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 环比变化率(百分数)。
|
||||
/// </summary>
|
||||
public decimal MomChangeRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 明细行响应项。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportBreakdownItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细键。
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(百分数)。
|
||||
/// </summary>
|
||||
public decimal RatioPercent { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表详情响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public string ReportId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型编码。
|
||||
/// </summary>
|
||||
public string PeriodType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// KPI 列表。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportKpiResponse> Kpis { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 收入明细(按渠道)。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportBreakdownItemResponse> IncomeBreakdowns { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细(按类别)。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportBreakdownItemResponse> CostBreakdowns { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表导出响应。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportExportResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 文件内容。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出总记录数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.TenantApi.Contracts.Finance;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 财务中心经营报表。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/finance/report")]
|
||||
public sealed class FinanceReportController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
private const string ViewPermission = "tenant:statistics:report:view";
|
||||
private const string ExportPermission = "tenant:statistics:report:export";
|
||||
|
||||
/// <summary>
|
||||
/// 查询经营报表列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportListResultResponse>> List(
|
||||
[FromQuery] FinanceBusinessReportListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析查询参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var periodType = ParsePeriodType(request.PeriodType);
|
||||
|
||||
// 2. 发起查询并返回结果。
|
||||
var result = await mediator.Send(new SearchFinanceBusinessReportListQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
PeriodType = periodType,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceBusinessReportListResultResponse>.Ok(new FinanceBusinessReportListResultResponse
|
||||
{
|
||||
Items = result.Items.Select(MapListItem).ToList(),
|
||||
Total = result.Total,
|
||||
Page = result.Page,
|
||||
PageSize = result.PageSize
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询经营报表详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[PermissionAuthorize(ViewPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportDetailResponse>> Detail(
|
||||
[FromQuery] FinanceBusinessReportDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
|
||||
|
||||
// 2. 发起详情查询。
|
||||
var detail = await mediator.Send(new GetFinanceBusinessReportDetailQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ReportId = reportId
|
||||
}, cancellationToken);
|
||||
|
||||
if (detail is null)
|
||||
{
|
||||
return ApiResponse<FinanceBusinessReportDetailResponse>.Error(ErrorCodes.NotFound, "经营报表不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<FinanceBusinessReportDetailResponse>.Ok(MapDetail(detail));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出单条报表 PDF。
|
||||
/// </summary>
|
||||
[HttpGet("export/pdf")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportExportResponse>> ExportPdf(
|
||||
[FromQuery] FinanceBusinessReportDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
|
||||
|
||||
// 2. 执行导出。
|
||||
var export = await mediator.Send(new ExportFinanceBusinessReportPdfQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ReportId = reportId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceBusinessReportExportResponse>.Ok(MapExport(export));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出单条报表 Excel。
|
||||
/// </summary>
|
||||
[HttpGet("export/excel")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportExportResponse>> ExportExcel(
|
||||
[FromQuery] FinanceBusinessReportDetailRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
|
||||
|
||||
// 2. 执行导出。
|
||||
var export = await mediator.Send(new ExportFinanceBusinessReportExcelQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ReportId = reportId
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceBusinessReportExportResponse>.Ok(MapExport(export));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量导出报表 ZIP(PDF + Excel)。
|
||||
/// </summary>
|
||||
[HttpGet("export/batch")]
|
||||
[PermissionAuthorize(ExportPermission)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FinanceBusinessReportExportResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FinanceBusinessReportExportResponse>> ExportBatch(
|
||||
[FromQuery] FinanceBusinessReportBatchExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验门店访问权限并解析参数。
|
||||
var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||
await EnsureStoreAccessibleAsync(storeId, cancellationToken);
|
||||
var periodType = ParsePeriodType(request.PeriodType);
|
||||
|
||||
// 2. 执行批量导出。
|
||||
var export = await mediator.Send(new ExportFinanceBusinessReportBatchQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
PeriodType = periodType,
|
||||
Page = Math.Max(1, request.Page),
|
||||
PageSize = Math.Clamp(request.PageSize, 1, 200)
|
||||
}, cancellationToken);
|
||||
|
||||
return ApiResponse<FinanceBusinessReportExportResponse>.Ok(MapExport(export));
|
||||
}
|
||||
|
||||
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportPeriodType ParsePeriodType(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"" or "daily" => FinanceBusinessReportPeriodType.Daily,
|
||||
"weekly" => FinanceBusinessReportPeriodType.Weekly,
|
||||
"monthly" => FinanceBusinessReportPeriodType.Monthly,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "periodType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportListItemResponse MapListItem(FinanceBusinessReportListItemDto source)
|
||||
{
|
||||
return new FinanceBusinessReportListItemResponse
|
||||
{
|
||||
ReportId = source.ReportId,
|
||||
DateText = source.DateText,
|
||||
RevenueAmount = source.RevenueAmount,
|
||||
OrderCount = source.OrderCount,
|
||||
AverageOrderValue = source.AverageOrderValue,
|
||||
RefundRatePercent = source.RefundRatePercent,
|
||||
CostTotalAmount = source.CostTotalAmount,
|
||||
NetProfitAmount = source.NetProfitAmount,
|
||||
ProfitRatePercent = source.ProfitRatePercent,
|
||||
Status = source.Status,
|
||||
StatusText = source.StatusText,
|
||||
CanDownload = source.CanDownload
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportDetailResponse MapDetail(FinanceBusinessReportDetailDto source)
|
||||
{
|
||||
return new FinanceBusinessReportDetailResponse
|
||||
{
|
||||
ReportId = source.ReportId,
|
||||
Title = source.Title,
|
||||
PeriodType = source.PeriodType,
|
||||
Status = source.Status,
|
||||
StatusText = source.StatusText,
|
||||
Kpis = source.Kpis.Select(item => new FinanceBusinessReportKpiResponse
|
||||
{
|
||||
Key = item.Key,
|
||||
Label = item.Label,
|
||||
ValueText = item.ValueText,
|
||||
YoyChangeRate = item.YoyChangeRate,
|
||||
MomChangeRate = item.MomChangeRate
|
||||
}).ToList(),
|
||||
IncomeBreakdowns = source.IncomeBreakdowns.Select(MapBreakdown).ToList(),
|
||||
CostBreakdowns = source.CostBreakdowns.Select(MapBreakdown).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportBreakdownItemResponse MapBreakdown(FinanceBusinessReportBreakdownItemDto source)
|
||||
{
|
||||
return new FinanceBusinessReportBreakdownItemResponse
|
||||
{
|
||||
Key = source.Key,
|
||||
Label = source.Label,
|
||||
Amount = source.Amount,
|
||||
RatioPercent = source.RatioPercent
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportExportResponse MapExport(FinanceBusinessReportExportDto source)
|
||||
{
|
||||
return new FinanceBusinessReportExportResponse
|
||||
{
|
||||
FileName = source.FileName,
|
||||
FileContentBase64 = source.FileContentBase64,
|
||||
TotalCount = source.TotalCount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表列表行 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public string ReportId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 日期文案。
|
||||
/// </summary>
|
||||
public string DateText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 营业额。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageOrderValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款率(百分数)。
|
||||
/// </summary>
|
||||
public decimal RefundRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本总额。
|
||||
/// </summary>
|
||||
public decimal CostTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 净利润。
|
||||
/// </summary>
|
||||
public decimal NetProfitAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 利润率(百分数)。
|
||||
/// </summary>
|
||||
public decimal ProfitRatePercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否可下载。
|
||||
/// </summary>
|
||||
public bool CanDownload { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表列表结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportListItemDto> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总数。
|
||||
/// </summary>
|
||||
public int Total { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表 KPI DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportKpiDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 指标键。
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 指标名称。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 指标值文本。
|
||||
/// </summary>
|
||||
public string ValueText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 同比变化率(百分数)。
|
||||
/// </summary>
|
||||
public decimal YoyChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 环比变化率(百分数)。
|
||||
/// </summary>
|
||||
public decimal MomChangeRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表明细行 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportBreakdownItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细键。
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(百分数)。
|
||||
/// </summary>
|
||||
public decimal RatioPercent { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public string ReportId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型编码。
|
||||
/// </summary>
|
||||
public string PeriodType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关键指标。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportKpiDto> Kpis { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 收入明细(按渠道)。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportBreakdownItemDto> IncomeBreakdowns { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细(按类别)。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportBreakdownItemDto> CostBreakdowns { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表导出 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportExportDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 文件内容。
|
||||
/// </summary>
|
||||
public string FileContentBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 总记录数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Domain.Finance.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表批量导出处理器(ZIP:PDF + Excel)。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceBusinessReportBatchQueryHandler(
|
||||
IFinanceBusinessReportRepository financeBusinessReportRepository,
|
||||
IFinanceBusinessReportExportService financeBusinessReportExportService,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportFinanceBusinessReportBatchQuery, FinanceBusinessReportExportDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceBusinessReportExportDto> Handle(
|
||||
ExportFinanceBusinessReportBatchQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取租户上下文并归一化分页参数。
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalizedPage = Math.Max(1, request.Page);
|
||||
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
|
||||
// 2. 确保成本配置并补齐快照。
|
||||
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
cancellationToken);
|
||||
await financeBusinessReportRepository.QueueSnapshotsForPageAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.PeriodType,
|
||||
normalizedPage,
|
||||
normalizedPageSize,
|
||||
cancellationToken);
|
||||
|
||||
// 3. 查询导出明细集合(允许实时补算)。
|
||||
var details = await financeBusinessReportRepository.ListBatchDetailsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.PeriodType,
|
||||
normalizedPage,
|
||||
normalizedPageSize,
|
||||
allowRealtimeBuild: true,
|
||||
cancellationToken);
|
||||
|
||||
// 4. 生成批量 PDF/Excel 并打包 ZIP。
|
||||
var periodCode = FinanceBusinessReportMapping.ToPeriodTypeCode(request.PeriodType);
|
||||
var zipBytes = await CreateZipAsync(
|
||||
details,
|
||||
periodCode,
|
||||
financeBusinessReportExportService,
|
||||
cancellationToken);
|
||||
|
||||
return new FinanceBusinessReportExportDto
|
||||
{
|
||||
FileName = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"business-report-batch-{request.StoreId}-{periodCode}-{DateTime.UtcNow:yyyyMMddHHmmss}.zip"),
|
||||
FileContentBase64 = Convert.ToBase64String(zipBytes),
|
||||
TotalCount = details.Count
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<byte[]> CreateZipAsync(
|
||||
IReadOnlyList<Domain.Finance.Models.FinanceBusinessReportDetailSnapshot> details,
|
||||
string periodCode,
|
||||
IFinanceBusinessReportExportService exportService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (details.Count == 0)
|
||||
{
|
||||
using var emptyStream = new MemoryStream();
|
||||
using (var emptyArchive = new ZipArchive(emptyStream, ZipArchiveMode.Create, true))
|
||||
{
|
||||
var entry = emptyArchive.CreateEntry("README.txt");
|
||||
await using var writer = new StreamWriter(entry.Open());
|
||||
await writer.WriteAsync("No business report data in current selection.");
|
||||
}
|
||||
|
||||
return emptyStream.ToArray();
|
||||
}
|
||||
|
||||
var pdfBytes = await exportService.ExportBatchPdfAsync(details, cancellationToken);
|
||||
var excelBytes = await exportService.ExportBatchExcelAsync(details, cancellationToken);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true))
|
||||
{
|
||||
var pdfEntry = archive.CreateEntry($"business-report-{periodCode}.pdf");
|
||||
await using (var pdfEntryStream = pdfEntry.Open())
|
||||
{
|
||||
await pdfEntryStream.WriteAsync(pdfBytes, cancellationToken);
|
||||
}
|
||||
|
||||
var excelEntry = archive.CreateEntry($"business-report-{periodCode}.xlsx");
|
||||
await using (var excelEntryStream = excelEntry.Open())
|
||||
{
|
||||
await excelEntryStream.WriteAsync(excelBytes, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Domain.Finance.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表 Excel 导出处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceBusinessReportExcelQueryHandler(
|
||||
IFinanceBusinessReportRepository financeBusinessReportRepository,
|
||||
IFinanceBusinessReportExportService financeBusinessReportExportService,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportFinanceBusinessReportExcelQuery, FinanceBusinessReportExportDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceBusinessReportExportDto> Handle(
|
||||
ExportFinanceBusinessReportExcelQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取租户上下文并确保成本配置存在。
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
cancellationToken);
|
||||
|
||||
// 2. 查询报表详情(允许实时补算)。
|
||||
var detail = await financeBusinessReportRepository.GetDetailAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.ReportId,
|
||||
allowRealtimeBuild: true,
|
||||
cancellationToken);
|
||||
|
||||
if (detail is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "经营报表不存在");
|
||||
}
|
||||
|
||||
// 3. 导出 Excel 并返回 Base64。
|
||||
var fileBytes = await financeBusinessReportExportService.ExportSingleExcelAsync(detail, cancellationToken);
|
||||
var periodCode = FinanceBusinessReportMapping.ToPeriodTypeCode(detail.PeriodType);
|
||||
return new FinanceBusinessReportExportDto
|
||||
{
|
||||
FileName = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"business-report-{request.StoreId}-{periodCode}-{request.ReportId}.xlsx"),
|
||||
FileContentBase64 = Convert.ToBase64String(fileBytes),
|
||||
TotalCount = 1
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Domain.Finance.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表 PDF 导出处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceBusinessReportPdfQueryHandler(
|
||||
IFinanceBusinessReportRepository financeBusinessReportRepository,
|
||||
IFinanceBusinessReportExportService financeBusinessReportExportService,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportFinanceBusinessReportPdfQuery, FinanceBusinessReportExportDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceBusinessReportExportDto> Handle(
|
||||
ExportFinanceBusinessReportPdfQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取租户上下文并确保成本配置存在。
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
cancellationToken);
|
||||
|
||||
// 2. 查询报表详情(允许实时补算)。
|
||||
var detail = await financeBusinessReportRepository.GetDetailAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.ReportId,
|
||||
allowRealtimeBuild: true,
|
||||
cancellationToken);
|
||||
|
||||
if (detail is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "经营报表不存在");
|
||||
}
|
||||
|
||||
// 3. 导出 PDF 并返回 Base64。
|
||||
var fileBytes = await financeBusinessReportExportService.ExportSinglePdfAsync(detail, cancellationToken);
|
||||
var periodCode = FinanceBusinessReportMapping.ToPeriodTypeCode(detail.PeriodType);
|
||||
return new FinanceBusinessReportExportDto
|
||||
{
|
||||
FileName = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"business-report-{request.StoreId}-{periodCode}-{request.ReportId}.pdf"),
|
||||
FileContentBase64 = Convert.ToBase64String(fileBytes),
|
||||
TotalCount = 1
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Globalization;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Finance.Models;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表映射工具。
|
||||
/// </summary>
|
||||
internal static class FinanceBusinessReportMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 映射列表行 DTO。
|
||||
/// </summary>
|
||||
public static FinanceBusinessReportListItemDto ToListItem(FinanceBusinessReportListItemSnapshot source)
|
||||
{
|
||||
return new FinanceBusinessReportListItemDto
|
||||
{
|
||||
ReportId = source.ReportId.ToString(CultureInfo.InvariantCulture),
|
||||
DateText = FormatPeriodText(source.PeriodType, source.PeriodStartAt, source.PeriodEndAt),
|
||||
RevenueAmount = RoundMoney(source.RevenueAmount),
|
||||
OrderCount = Math.Max(0, source.OrderCount),
|
||||
AverageOrderValue = RoundMoney(source.AverageOrderValue),
|
||||
RefundRatePercent = RoundPercent(source.RefundRate),
|
||||
CostTotalAmount = RoundMoney(source.CostTotalAmount),
|
||||
NetProfitAmount = RoundMoney(source.NetProfitAmount),
|
||||
ProfitRatePercent = RoundPercent(source.ProfitRate),
|
||||
Status = ToStatusCode(source.Status),
|
||||
StatusText = ToStatusText(source.Status),
|
||||
CanDownload = source.Status == FinanceBusinessReportStatus.Succeeded
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 映射详情 DTO。
|
||||
/// </summary>
|
||||
public static FinanceBusinessReportDetailDto ToDetail(FinanceBusinessReportDetailSnapshot source)
|
||||
{
|
||||
return new FinanceBusinessReportDetailDto
|
||||
{
|
||||
ReportId = source.ReportId.ToString(CultureInfo.InvariantCulture),
|
||||
Title = BuildTitle(source.PeriodType, source.PeriodStartAt, source.PeriodEndAt),
|
||||
PeriodType = ToPeriodTypeCode(source.PeriodType),
|
||||
Status = ToStatusCode(source.Status),
|
||||
StatusText = ToStatusText(source.Status),
|
||||
Kpis = source.Kpis.Select(ToKpi).ToList(),
|
||||
IncomeBreakdowns = source.IncomeBreakdowns.Select(ToBreakdown).ToList(),
|
||||
CostBreakdowns = source.CostBreakdowns.Select(ToBreakdown).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型编码。
|
||||
/// </summary>
|
||||
public static string ToPeriodTypeCode(FinanceBusinessReportPeriodType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
FinanceBusinessReportPeriodType.Daily => "daily",
|
||||
FinanceBusinessReportPeriodType.Weekly => "weekly",
|
||||
FinanceBusinessReportPeriodType.Monthly => "monthly",
|
||||
_ => "daily"
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportKpiDto ToKpi(FinanceBusinessReportKpiSnapshot source)
|
||||
{
|
||||
return new FinanceBusinessReportKpiDto
|
||||
{
|
||||
Key = source.Key,
|
||||
Label = source.Label,
|
||||
ValueText = FormatKpiValue(source.Key, source.Value),
|
||||
YoyChangeRate = RoundRate(source.YoyChangeRate),
|
||||
MomChangeRate = RoundRate(source.MomChangeRate)
|
||||
};
|
||||
}
|
||||
|
||||
private static FinanceBusinessReportBreakdownItemDto ToBreakdown(FinanceBusinessReportBreakdownSnapshot source)
|
||||
{
|
||||
return new FinanceBusinessReportBreakdownItemDto
|
||||
{
|
||||
Key = source.Key,
|
||||
Label = source.Label,
|
||||
Amount = RoundMoney(source.Amount),
|
||||
RatioPercent = RoundPercent(source.Ratio)
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatPeriodText(
|
||||
FinanceBusinessReportPeriodType periodType,
|
||||
DateTime startAt,
|
||||
DateTime endAt)
|
||||
{
|
||||
return periodType switch
|
||||
{
|
||||
FinanceBusinessReportPeriodType.Daily => startAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
FinanceBusinessReportPeriodType.Weekly =>
|
||||
$"{startAt:MM-dd} ~ {endAt.AddDays(-1):MM-dd}",
|
||||
FinanceBusinessReportPeriodType.Monthly => startAt.ToString("yyyy年M月", CultureInfo.InvariantCulture),
|
||||
_ => startAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTitle(
|
||||
FinanceBusinessReportPeriodType periodType,
|
||||
DateTime startAt,
|
||||
DateTime endAt)
|
||||
{
|
||||
return periodType switch
|
||||
{
|
||||
FinanceBusinessReportPeriodType.Daily => $"{startAt:yyyy年M月d日} 经营日报",
|
||||
FinanceBusinessReportPeriodType.Weekly => $"{startAt:yyyy年M月d日}~{endAt.AddDays(-1):M月d日} 经营周报",
|
||||
FinanceBusinessReportPeriodType.Monthly => $"{startAt:yyyy年M月} 经营月报",
|
||||
_ => "经营报表"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToStatusCode(FinanceBusinessReportStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
FinanceBusinessReportStatus.Queued => "queued",
|
||||
FinanceBusinessReportStatus.Running => "running",
|
||||
FinanceBusinessReportStatus.Succeeded => "succeeded",
|
||||
FinanceBusinessReportStatus.Failed => "failed",
|
||||
_ => "queued"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToStatusText(FinanceBusinessReportStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
FinanceBusinessReportStatus.Queued => "排队中",
|
||||
FinanceBusinessReportStatus.Running => "生成中",
|
||||
FinanceBusinessReportStatus.Succeeded => "已生成",
|
||||
FinanceBusinessReportStatus.Failed => "生成失败",
|
||||
_ => "排队中"
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatKpiValue(string key, decimal value)
|
||||
{
|
||||
if (key is "order_count")
|
||||
{
|
||||
return Math.Round(value, 0, MidpointRounding.AwayFromZero).ToString("0", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (key is "refund_rate" or "profit_rate")
|
||||
{
|
||||
return $"{RoundPercent(value):0.##}%";
|
||||
}
|
||||
|
||||
if (key is "average_order_value")
|
||||
{
|
||||
return $"¥{RoundMoney(value):0.##}";
|
||||
}
|
||||
|
||||
return $"¥{RoundMoney(value):0.##}";
|
||||
}
|
||||
|
||||
private static decimal RoundMoney(decimal value)
|
||||
{
|
||||
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal RoundPercent(decimal value)
|
||||
{
|
||||
return decimal.Round(value * 100m, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal RoundRate(decimal value)
|
||||
{
|
||||
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceBusinessReportDetailQueryHandler(
|
||||
IFinanceBusinessReportRepository financeBusinessReportRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFinanceBusinessReportDetailQuery, FinanceBusinessReportDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceBusinessReportDetailDto?> Handle(
|
||||
GetFinanceBusinessReportDetailQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取租户上下文并确保成本配置存在。
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
cancellationToken);
|
||||
|
||||
// 2. 查询详情(允许实时补算)并映射输出。
|
||||
var detail = await financeBusinessReportRepository.GetDetailAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.ReportId,
|
||||
allowRealtimeBuild: true,
|
||||
cancellationToken);
|
||||
|
||||
return detail is null ? null : FinanceBusinessReportMapping.ToDetail(detail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表分页查询处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchFinanceBusinessReportListQueryHandler(
|
||||
IFinanceBusinessReportRepository financeBusinessReportRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SearchFinanceBusinessReportListQuery, FinanceBusinessReportListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceBusinessReportListResultDto> Handle(
|
||||
SearchFinanceBusinessReportListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取租户上下文并归一化分页参数。
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalizedPage = Math.Max(1, request.Page);
|
||||
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
|
||||
// 2. 确保成本配置并补齐分页周期快照。
|
||||
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
cancellationToken);
|
||||
await financeBusinessReportRepository.QueueSnapshotsForPageAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.PeriodType,
|
||||
normalizedPage,
|
||||
normalizedPageSize,
|
||||
cancellationToken);
|
||||
|
||||
// 3. 查询分页快照并映射输出。
|
||||
var pageSnapshot = await financeBusinessReportRepository.SearchPageAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
request.PeriodType,
|
||||
normalizedPage,
|
||||
normalizedPageSize,
|
||||
cancellationToken);
|
||||
|
||||
return new FinanceBusinessReportListResultDto
|
||||
{
|
||||
Items = pageSnapshot.Items.Select(FinanceBusinessReportMapping.ToListItem).ToList(),
|
||||
Total = pageSnapshot.TotalCount,
|
||||
Page = normalizedPage,
|
||||
PageSize = normalizedPageSize
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 批量导出经营报表(ZIP:PDF + Excel)。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceBusinessReportBatchQuery : IRequest<FinanceBusinessReportExportDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型。
|
||||
/// </summary>
|
||||
public FinanceBusinessReportPeriodType PeriodType { get; init; } = FinanceBusinessReportPeriodType.Daily;
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 导出经营报表 Excel。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceBusinessReportExcelQuery : IRequest<FinanceBusinessReportExportDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public long ReportId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 导出经营报表 PDF。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceBusinessReportPdfQuery : IRequest<FinanceBusinessReportExportDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public long ReportId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询经营报表详情。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceBusinessReportDetailQuery : IRequest<FinanceBusinessReportDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public long ReportId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询经营报表分页列表。
|
||||
/// </summary>
|
||||
public sealed class SearchFinanceBusinessReportListQuery : IRequest<FinanceBusinessReportListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型。
|
||||
/// </summary>
|
||||
public FinanceBusinessReportPeriodType PeriodType { get; init; } = FinanceBusinessReportPeriodType.Daily;
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表批量导出查询验证器。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceBusinessReportBatchQueryValidator : AbstractValidator<ExportFinanceBusinessReportBatchQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public ExportFinanceBusinessReportBatchQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.Page).GreaterThan(0);
|
||||
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表 Excel 导出查询验证器。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceBusinessReportExcelQueryValidator : AbstractValidator<ExportFinanceBusinessReportExcelQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public ExportFinanceBusinessReportExcelQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.ReportId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表 PDF 导出查询验证器。
|
||||
/// </summary>
|
||||
public sealed class ExportFinanceBusinessReportPdfQueryValidator : AbstractValidator<ExportFinanceBusinessReportPdfQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public ExportFinanceBusinessReportPdfQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.ReportId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表详情查询验证器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceBusinessReportDetailQueryValidator : AbstractValidator<GetFinanceBusinessReportDetailQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public GetFinanceBusinessReportDetailQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.ReportId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表分页查询验证器。
|
||||
/// </summary>
|
||||
public sealed class SearchFinanceBusinessReportListQueryValidator : AbstractValidator<SearchFinanceBusinessReportListQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public SearchFinanceBusinessReportListQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.StoreId).GreaterThan(0);
|
||||
RuleFor(x => x.Page).GreaterThan(0);
|
||||
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表快照实体。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportSnapshot : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型。
|
||||
/// </summary>
|
||||
public FinanceBusinessReportPeriodType PeriodType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期开始时间(UTC,含)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStartAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期结束时间(UTC,不含)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEndAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 生成状态。
|
||||
/// </summary>
|
||||
public FinanceBusinessReportStatus Status { get; set; } = FinanceBusinessReportStatus.Queued;
|
||||
|
||||
/// <summary>
|
||||
/// 营业额。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageOrderValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款率(0-1)。
|
||||
/// </summary>
|
||||
public decimal RefundRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本总额。
|
||||
/// </summary>
|
||||
public decimal CostTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 净利润。
|
||||
/// </summary>
|
||||
public decimal NetProfitAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 利润率(0-1)。
|
||||
/// </summary>
|
||||
public decimal ProfitRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// KPI 比较快照 JSON(同比/环比)。
|
||||
/// </summary>
|
||||
public string KpiComparisonJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// 收入明细快照 JSON(按渠道)。
|
||||
/// </summary>
|
||||
public string IncomeBreakdownJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细快照 JSON(按类别)。
|
||||
/// </summary>
|
||||
public string CostBreakdownJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// 生成开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? StartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 生成完成时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? FinishedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近一次失败信息。
|
||||
/// </summary>
|
||||
public string? LastError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 重试次数。
|
||||
/// </summary>
|
||||
public int RetryCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 调度任务 ID。
|
||||
/// </summary>
|
||||
public string? HangfireJobId { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 成本日覆盖实体。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostDailyOverride : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 业务日期(UTC 日期)。
|
||||
/// </summary>
|
||||
public DateTime BusinessDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类。
|
||||
/// </summary>
|
||||
public FinanceCostCategory Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 成本配置实体(类别级规则)。
|
||||
/// </summary>
|
||||
public sealed class FinanceCostProfile : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本分类。
|
||||
/// </summary>
|
||||
public FinanceCostCategory Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 计算模式。
|
||||
/// </summary>
|
||||
public FinanceCostCalcMode CalcMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 比例值(0-1,Ratio 模式使用)。
|
||||
/// </summary>
|
||||
public decimal Ratio { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定日金额(FixedDaily 模式使用)。
|
||||
/// </summary>
|
||||
public decimal FixedDailyAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 生效开始日期(UTC 日期)。
|
||||
/// </summary>
|
||||
public DateTime EffectiveFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 生效结束日期(UTC 日期,含,null 表示长期)。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; } = 100;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表周期类型。
|
||||
/// </summary>
|
||||
public enum FinanceBusinessReportPeriodType
|
||||
{
|
||||
/// <summary>
|
||||
/// 日报。
|
||||
/// </summary>
|
||||
Daily = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 周报。
|
||||
/// </summary>
|
||||
Weekly = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 月报。
|
||||
/// </summary>
|
||||
Monthly = 3
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表快照状态。
|
||||
/// </summary>
|
||||
public enum FinanceBusinessReportStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 已排队。
|
||||
/// </summary>
|
||||
Queued = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 生成中。
|
||||
/// </summary>
|
||||
Running = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 已生成。
|
||||
/// </summary>
|
||||
Succeeded = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 生成失败。
|
||||
/// </summary>
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 成本计算模式。
|
||||
/// </summary>
|
||||
public enum FinanceCostCalcMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 按营业额比例计算。
|
||||
/// </summary>
|
||||
Ratio = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 按固定日金额计算。
|
||||
/// </summary>
|
||||
FixedDaily = 2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表 KPI 快照项。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportKpiSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 指标键。
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 指标名称。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 指标值。
|
||||
/// </summary>
|
||||
public decimal Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 同比变化率(百分数,如 3.5 表示 +3.5%)。
|
||||
/// </summary>
|
||||
public decimal YoyChangeRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 环比变化率(百分数,如 2.1 表示 +2.1%)。
|
||||
/// </summary>
|
||||
public decimal MomChangeRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表明细行快照。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportBreakdownSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 明细键。
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 明细名称。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 占比(0-1)。
|
||||
/// </summary>
|
||||
public decimal Ratio { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表列表行快照。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportListItemSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public long ReportId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型。
|
||||
/// </summary>
|
||||
public FinanceBusinessReportPeriodType PeriodType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期开始时间(UTC,含)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStartAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期结束时间(UTC,不含)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEndAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public FinanceBusinessReportStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营业额。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageOrderValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款率(0-1)。
|
||||
/// </summary>
|
||||
public decimal RefundRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本总额。
|
||||
/// </summary>
|
||||
public decimal CostTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 净利润。
|
||||
/// </summary>
|
||||
public decimal NetProfitAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 利润率(0-1)。
|
||||
/// </summary>
|
||||
public decimal ProfitRate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表分页快照。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportPageSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportListItemSnapshot> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表详情快照。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportDetailSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 报表 ID。
|
||||
/// </summary>
|
||||
public long ReportId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期类型。
|
||||
/// </summary>
|
||||
public FinanceBusinessReportPeriodType PeriodType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期开始时间(UTC,含)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStartAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期结束时间(UTC,不含)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEndAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public FinanceBusinessReportStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 营业额。
|
||||
/// </summary>
|
||||
public decimal RevenueAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单数。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价。
|
||||
/// </summary>
|
||||
public decimal AverageOrderValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 退款率(0-1)。
|
||||
/// </summary>
|
||||
public decimal RefundRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本总额。
|
||||
/// </summary>
|
||||
public decimal CostTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 净利润。
|
||||
/// </summary>
|
||||
public decimal NetProfitAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 利润率(0-1)。
|
||||
/// </summary>
|
||||
public decimal ProfitRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键指标快照列表。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportKpiSnapshot> Kpis { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 收入明细(按渠道)。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportBreakdownSnapshot> IncomeBreakdowns { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 成本明细(按类别)。
|
||||
/// </summary>
|
||||
public List<FinanceBusinessReportBreakdownSnapshot> CostBreakdowns { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 待处理报表任务快照。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportPendingSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 快照 ID。
|
||||
/// </summary>
|
||||
public long SnapshotId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public long TenantId { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Finance.Models;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表仓储契约。
|
||||
/// </summary>
|
||||
public interface IFinanceBusinessReportRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 确保门店存在默认成本配置。
|
||||
/// </summary>
|
||||
Task EnsureDefaultCostProfilesAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 为指定分页周期补齐快照并排队。
|
||||
/// </summary>
|
||||
Task QueueSnapshotsForPageAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
FinanceBusinessReportPeriodType periodType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询经营报表分页结果。
|
||||
/// </summary>
|
||||
Task<FinanceBusinessReportPageSnapshot> SearchPageAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
FinanceBusinessReportPeriodType periodType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询经营报表详情。
|
||||
/// </summary>
|
||||
Task<FinanceBusinessReportDetailSnapshot?> GetDetailAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
long reportId,
|
||||
bool allowRealtimeBuild,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询批量导出详情集合。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FinanceBusinessReportDetailSnapshot>> ListBatchDetailsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
FinanceBusinessReportPeriodType periodType,
|
||||
int page,
|
||||
int pageSize,
|
||||
bool allowRealtimeBuild,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 拉取待处理任务。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FinanceBusinessReportPendingSnapshot>> GetPendingSnapshotsAsync(
|
||||
int take,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 执行报表快照生成。
|
||||
/// </summary>
|
||||
Task GenerateSnapshotAsync(
|
||||
long snapshotId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using TakeoutSaaS.Domain.Finance.Models;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Finance.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表导出服务契约。
|
||||
/// </summary>
|
||||
public interface IFinanceBusinessReportExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// 导出单条报表 PDF。
|
||||
/// </summary>
|
||||
Task<byte[]> ExportSinglePdfAsync(
|
||||
FinanceBusinessReportDetailSnapshot detail,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 导出单条报表 Excel。
|
||||
/// </summary>
|
||||
Task<byte[]> ExportSingleExcelAsync(
|
||||
FinanceBusinessReportDetailSnapshot detail,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 导出批量报表 PDF。
|
||||
/// </summary>
|
||||
Task<byte[]> ExportBatchPdfAsync(
|
||||
IReadOnlyList<FinanceBusinessReportDetailSnapshot> details,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 导出批量报表 Excel。
|
||||
/// </summary>
|
||||
Task<byte[]> ExportBatchExcelAsync(
|
||||
IReadOnlyList<FinanceBusinessReportDetailSnapshot> details,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -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<IStoredCardRepository, EfStoredCardRepository>();
|
||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||
services.AddScoped<IFinanceCostRepository, EfFinanceCostRepository>();
|
||||
services.AddScoped<IFinanceBusinessReportRepository, EfFinanceBusinessReportRepository>();
|
||||
services.AddScoped<IFinanceTransactionRepository, EfFinanceTransactionRepository>();
|
||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||
@@ -80,6 +82,7 @@ public static class AppServiceCollectionExtensions
|
||||
services.AddScoped<IBillingDomainService, BillingDomainService>();
|
||||
services.AddScoped<IBillingExportService, BillingExportService>();
|
||||
services.AddScoped<IMerchantExportService, MerchantExportService>();
|
||||
services.AddScoped<IFinanceBusinessReportExportService, FinanceBusinessReportExportService>();
|
||||
|
||||
// 2. (空行后) 门店配置服务
|
||||
services.AddScoped<IGeoJsonValidationService, GeoJsonValidationService>();
|
||||
|
||||
@@ -103,6 +103,18 @@ public sealed class TakeoutAppDbContext(
|
||||
/// </summary>
|
||||
public DbSet<TenantInvoiceRecord> TenantInvoiceRecords => Set<TenantInvoiceRecord>();
|
||||
/// <summary>
|
||||
/// 经营报表快照。
|
||||
/// </summary>
|
||||
public DbSet<FinanceBusinessReportSnapshot> FinanceBusinessReportSnapshots => Set<FinanceBusinessReportSnapshot>();
|
||||
/// <summary>
|
||||
/// 成本配置。
|
||||
/// </summary>
|
||||
public DbSet<FinanceCostProfile> FinanceCostProfiles => Set<FinanceCostProfile>();
|
||||
/// <summary>
|
||||
/// 成本日覆盖。
|
||||
/// </summary>
|
||||
public DbSet<FinanceCostDailyOverride> FinanceCostDailyOverrides => Set<FinanceCostDailyOverride>();
|
||||
/// <summary>
|
||||
/// 成本录入汇总。
|
||||
/// </summary>
|
||||
public DbSet<FinanceCostEntry> FinanceCostEntries => Set<FinanceCostEntry>();
|
||||
@@ -544,6 +556,9 @@ public sealed class TakeoutAppDbContext(
|
||||
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity<TenantVisibilityRoleRule>());
|
||||
ConfigureTenantInvoiceSetting(modelBuilder.Entity<TenantInvoiceSetting>());
|
||||
ConfigureTenantInvoiceRecord(modelBuilder.Entity<TenantInvoiceRecord>());
|
||||
ConfigureFinanceBusinessReportSnapshot(modelBuilder.Entity<FinanceBusinessReportSnapshot>());
|
||||
ConfigureFinanceCostProfile(modelBuilder.Entity<FinanceCostProfile>());
|
||||
ConfigureFinanceCostDailyOverride(modelBuilder.Entity<FinanceCostDailyOverride>());
|
||||
ConfigureFinanceCostEntry(modelBuilder.Entity<FinanceCostEntry>());
|
||||
ConfigureFinanceCostEntryItem(modelBuilder.Entity<FinanceCostEntryItem>());
|
||||
ConfigureQuotaPackage(modelBuilder.Entity<QuotaPackage>());
|
||||
@@ -1109,6 +1124,68 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => new { x.TenantId, x.InvoiceType, x.AppliedAt });
|
||||
}
|
||||
|
||||
private static void ConfigureFinanceBusinessReportSnapshot(EntityTypeBuilder<FinanceBusinessReportSnapshot> builder)
|
||||
{
|
||||
builder.ToTable("finance_business_report_snapshots");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.PeriodType).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.PeriodStartAt).IsRequired();
|
||||
builder.Property(x => x.PeriodEndAt).IsRequired();
|
||||
builder.Property(x => x.Status).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.RevenueAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.OrderCount).IsRequired();
|
||||
builder.Property(x => x.AverageOrderValue).HasPrecision(18, 2);
|
||||
builder.Property(x => x.RefundRate).HasPrecision(9, 4);
|
||||
builder.Property(x => x.CostTotalAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.NetProfitAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.ProfitRate).HasPrecision(9, 4);
|
||||
builder.Property(x => x.KpiComparisonJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.IncomeBreakdownJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.CostBreakdownJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.LastError).HasMaxLength(1024);
|
||||
builder.Property(x => x.HangfireJobId).HasMaxLength(64);
|
||||
builder.Property(x => x.RetryCount).HasDefaultValue(0);
|
||||
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PeriodType, x.PeriodStartAt }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PeriodType, x.Status, x.PeriodStartAt });
|
||||
builder.HasIndex(x => new { x.TenantId, x.Status, x.CreatedAt });
|
||||
}
|
||||
|
||||
private static void ConfigureFinanceCostProfile(EntityTypeBuilder<FinanceCostProfile> builder)
|
||||
{
|
||||
builder.ToTable("finance_cost_profiles");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.Category).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.CalcMode).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.Ratio).HasPrecision(9, 6).IsRequired();
|
||||
builder.Property(x => x.FixedDailyAmount).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.EffectiveFrom).IsRequired();
|
||||
builder.Property(x => x.EffectiveTo);
|
||||
builder.Property(x => x.IsEnabled).IsRequired();
|
||||
builder.Property(x => x.SortOrder).HasDefaultValue(100);
|
||||
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Category, x.EffectiveFrom, x.EffectiveTo });
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.IsEnabled, x.SortOrder });
|
||||
}
|
||||
|
||||
private static void ConfigureFinanceCostDailyOverride(EntityTypeBuilder<FinanceCostDailyOverride> builder)
|
||||
{
|
||||
builder.ToTable("finance_cost_daily_overrides");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.BusinessDate).IsRequired();
|
||||
builder.Property(x => x.Category).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.Remark).HasMaxLength(256);
|
||||
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.BusinessDate, x.Category }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.BusinessDate });
|
||||
}
|
||||
private static void ConfigureFinanceCostEntry(EntityTypeBuilder<FinanceCostEntry> builder)
|
||||
{
|
||||
builder.ToTable("finance_cost_entries");
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Finance.Entities;
|
||||
using TakeoutSaaS.Domain.Finance.Enums;
|
||||
using TakeoutSaaS.Domain.Finance.Models;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfFinanceBusinessReportRepository(
|
||||
TakeoutAppDbContext context,
|
||||
ITenantContextAccessor tenantContextAccessor) : IFinanceBusinessReportRepository
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly FinanceCostCategory[] CostCategoryOrder =
|
||||
[
|
||||
FinanceCostCategory.FoodMaterial,
|
||||
FinanceCostCategory.Labor,
|
||||
FinanceCostCategory.FixedExpense,
|
||||
FinanceCostCategory.PackagingConsumable
|
||||
];
|
||||
private static readonly DeliveryType[] IncomeChannelOrder =
|
||||
[
|
||||
DeliveryType.Delivery,
|
||||
DeliveryType.Pickup,
|
||||
DeliveryType.DineIn
|
||||
];
|
||||
private static readonly IReadOnlyDictionary<FinanceCostCategory, (FinanceCostCalcMode Mode, decimal Ratio, decimal Fixed)> DefaultCostProfileMap =
|
||||
new Dictionary<FinanceCostCategory, (FinanceCostCalcMode, decimal, decimal)>
|
||||
{
|
||||
[FinanceCostCategory.FoodMaterial] = (FinanceCostCalcMode.Ratio, 0.36m, 0m),
|
||||
[FinanceCostCategory.Labor] = (FinanceCostCalcMode.Ratio, 0.19m, 0m),
|
||||
[FinanceCostCategory.FixedExpense] = (FinanceCostCalcMode.FixedDaily, 0m, 190m),
|
||||
[FinanceCostCategory.PackagingConsumable] = (FinanceCostCalcMode.Ratio, 0.04m, 0m)
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EnsureDefaultCostProfilesAsync(long tenantId, long storeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId <= 0 || storeId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await context.FinanceCostProfiles
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null)
|
||||
.Select(item => item.Category)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
var missing = CostCategoryOrder.Where(item => !existing.Contains(item)).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveFrom = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var entities = missing.Select((category, index) =>
|
||||
{
|
||||
var profile = DefaultCostProfileMap[category];
|
||||
return new FinanceCostProfile
|
||||
{
|
||||
TenantId = tenantId,
|
||||
StoreId = storeId,
|
||||
Category = category,
|
||||
CalcMode = profile.Mode,
|
||||
Ratio = profile.Ratio,
|
||||
FixedDailyAmount = profile.Fixed,
|
||||
EffectiveFrom = effectiveFrom,
|
||||
IsEnabled = true,
|
||||
SortOrder = (index + 1) * 10
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
await context.FinanceCostProfiles.AddRangeAsync(entities, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task QueueSnapshotsForPageAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId <= 0 || storeId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var periods = BuildPagedPeriods(periodType, page, pageSize, now);
|
||||
var starts = periods.Select(item => item.StartAt).ToHashSet();
|
||||
var existing = await context.FinanceBusinessReportSnapshots
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StoreId == storeId &&
|
||||
item.PeriodType == periodType &&
|
||||
item.DeletedAt == null &&
|
||||
starts.Contains(item.PeriodStartAt))
|
||||
.OrderByDescending(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var map = existing.GroupBy(item => item.PeriodStartAt).ToDictionary(group => group.Key, group => group.First());
|
||||
|
||||
var changed = false;
|
||||
foreach (var period in periods)
|
||||
{
|
||||
if (!map.TryGetValue(period.StartAt, out var snapshot))
|
||||
{
|
||||
await context.FinanceBusinessReportSnapshots.AddAsync(new FinanceBusinessReportSnapshot
|
||||
{
|
||||
TenantId = tenantId,
|
||||
StoreId = storeId,
|
||||
PeriodType = periodType,
|
||||
PeriodStartAt = period.StartAt,
|
||||
PeriodEndAt = period.EndAt,
|
||||
Status = FinanceBusinessReportStatus.Queued
|
||||
}, cancellationToken);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (snapshot.PeriodEndAt != period.EndAt)
|
||||
{
|
||||
snapshot.PeriodEndAt = period.EndAt;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (snapshot.Status == FinanceBusinessReportStatus.Failed && snapshot.RetryCount < 5)
|
||||
{
|
||||
snapshot.Status = FinanceBusinessReportStatus.Queued;
|
||||
snapshot.LastError = null;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (now >= period.StartAt
|
||||
&& now < period.EndAt
|
||||
&& snapshot.Status == FinanceBusinessReportStatus.Succeeded
|
||||
&& (!snapshot.FinishedAt.HasValue || snapshot.FinishedAt.Value.AddMinutes(30) <= now))
|
||||
{
|
||||
snapshot.Status = FinanceBusinessReportStatus.Queued;
|
||||
snapshot.StartedAt = null;
|
||||
snapshot.FinishedAt = null;
|
||||
snapshot.LastError = null;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceBusinessReportPageSnapshot> SearchPageAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedPage = Math.Max(1, page);
|
||||
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
|
||||
var query = context.FinanceBusinessReportSnapshots
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.StoreId == storeId &&
|
||||
item.PeriodType == periodType &&
|
||||
item.DeletedAt == null);
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
if (totalCount == 0)
|
||||
{
|
||||
return new FinanceBusinessReportPageSnapshot();
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.PeriodStartAt)
|
||||
.ThenByDescending(item => item.Id)
|
||||
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||
.Take(normalizedPageSize)
|
||||
.Select(item => new FinanceBusinessReportListItemSnapshot
|
||||
{
|
||||
ReportId = item.Id,
|
||||
PeriodType = item.PeriodType,
|
||||
PeriodStartAt = item.PeriodStartAt,
|
||||
PeriodEndAt = item.PeriodEndAt,
|
||||
Status = item.Status,
|
||||
RevenueAmount = item.RevenueAmount,
|
||||
OrderCount = item.OrderCount,
|
||||
AverageOrderValue = item.AverageOrderValue,
|
||||
RefundRate = item.RefundRate,
|
||||
CostTotalAmount = item.CostTotalAmount,
|
||||
NetProfitAmount = item.NetProfitAmount,
|
||||
ProfitRate = item.ProfitRate
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new FinanceBusinessReportPageSnapshot
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceBusinessReportDetailSnapshot?> GetDetailAsync(long tenantId, long storeId, long reportId, bool allowRealtimeBuild, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await context.FinanceBusinessReportSnapshots
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == reportId && item.DeletedAt == null, cancellationToken);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (allowRealtimeBuild && snapshot.Status != FinanceBusinessReportStatus.Succeeded)
|
||||
{
|
||||
await GenerateSnapshotAsync(reportId, cancellationToken);
|
||||
snapshot = await context.FinanceBusinessReportSnapshots
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == reportId && item.DeletedAt == null, cancellationToken);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new FinanceBusinessReportDetailSnapshot
|
||||
{
|
||||
ReportId = snapshot.Id,
|
||||
StoreId = snapshot.StoreId,
|
||||
PeriodType = snapshot.PeriodType,
|
||||
PeriodStartAt = snapshot.PeriodStartAt,
|
||||
PeriodEndAt = snapshot.PeriodEndAt,
|
||||
Status = snapshot.Status,
|
||||
RevenueAmount = snapshot.RevenueAmount,
|
||||
OrderCount = snapshot.OrderCount,
|
||||
AverageOrderValue = snapshot.AverageOrderValue,
|
||||
RefundRate = snapshot.RefundRate,
|
||||
CostTotalAmount = snapshot.CostTotalAmount,
|
||||
NetProfitAmount = snapshot.NetProfitAmount,
|
||||
ProfitRate = snapshot.ProfitRate,
|
||||
Kpis = Deserialize<FinanceBusinessReportKpiSnapshot>(snapshot.KpiComparisonJson),
|
||||
IncomeBreakdowns = Deserialize<FinanceBusinessReportBreakdownSnapshot>(snapshot.IncomeBreakdownJson),
|
||||
CostBreakdowns = Deserialize<FinanceBusinessReportBreakdownSnapshot>(snapshot.CostBreakdownJson)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FinanceBusinessReportDetailSnapshot>> ListBatchDetailsAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, bool allowRealtimeBuild, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedPage = Math.Max(1, page);
|
||||
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
|
||||
var reportIds = await context.FinanceBusinessReportSnapshots
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.PeriodType == periodType && item.DeletedAt == null)
|
||||
.OrderByDescending(item => item.PeriodStartAt)
|
||||
.ThenByDescending(item => item.Id)
|
||||
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||
.Take(normalizedPageSize)
|
||||
.Select(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var list = new List<FinanceBusinessReportDetailSnapshot>(reportIds.Count);
|
||||
foreach (var reportId in reportIds)
|
||||
{
|
||||
var detail = await GetDetailAsync(tenantId, storeId, reportId, allowRealtimeBuild, cancellationToken);
|
||||
if (detail is not null)
|
||||
{
|
||||
list.Add(detail);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FinanceBusinessReportPendingSnapshot>> GetPendingSnapshotsAsync(int take, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedTake = Math.Clamp(take, 1, 200);
|
||||
if ((tenantContextAccessor.Current?.TenantId ?? 0) != 0)
|
||||
{
|
||||
return await QueryPendingAsync(normalizedTake, cancellationToken);
|
||||
}
|
||||
|
||||
var tenantIds = await context.Tenants.AsNoTracking().Where(item => item.DeletedAt == null && item.Id > 0).Select(item => item.Id).ToListAsync(cancellationToken);
|
||||
var pending = new List<(long SnapshotId, long TenantId, DateTime CreatedAt)>();
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId, "finance-report"))
|
||||
{
|
||||
var rows = await context.FinanceBusinessReportSnapshots
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.DeletedAt == null &&
|
||||
(item.Status == FinanceBusinessReportStatus.Queued || (item.Status == FinanceBusinessReportStatus.Failed && item.RetryCount < 3)))
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ThenBy(item => item.Id)
|
||||
.Take(normalizedTake)
|
||||
.Select(item => new { item.Id, item.TenantId, item.CreatedAt })
|
||||
.ToListAsync(cancellationToken);
|
||||
pending.AddRange(rows.Select(item => (item.Id, item.TenantId, item.CreatedAt)));
|
||||
}
|
||||
}
|
||||
|
||||
return pending.OrderBy(item => item.CreatedAt).ThenBy(item => item.SnapshotId).Take(normalizedTake).Select(item => new FinanceBusinessReportPendingSnapshot { SnapshotId = item.SnapshotId, TenantId = item.TenantId }).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task GenerateSnapshotAsync(long snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await context.FinanceBusinessReportSnapshots.FirstOrDefaultAsync(item => item.Id == snapshotId && item.DeletedAt == null, cancellationToken);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot.Status == FinanceBusinessReportStatus.Running
|
||||
&& snapshot.StartedAt.HasValue
|
||||
&& snapshot.StartedAt.Value.AddMinutes(10) > DateTime.UtcNow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot.Status = FinanceBusinessReportStatus.Running;
|
||||
snapshot.StartedAt = DateTime.UtcNow;
|
||||
snapshot.LastError = null;
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await EnsureDefaultCostProfilesAsync(snapshot.TenantId, snapshot.StoreId, cancellationToken);
|
||||
var report = await BuildComputedSnapshotAsync(snapshot.TenantId, snapshot.StoreId, snapshot.PeriodType, snapshot.PeriodStartAt, snapshot.PeriodEndAt, cancellationToken);
|
||||
snapshot.RevenueAmount = report.RevenueAmount;
|
||||
snapshot.OrderCount = report.OrderCount;
|
||||
snapshot.AverageOrderValue = report.AverageOrderValue;
|
||||
snapshot.RefundRate = report.RefundRate;
|
||||
snapshot.CostTotalAmount = report.CostTotalAmount;
|
||||
snapshot.NetProfitAmount = report.NetProfitAmount;
|
||||
snapshot.ProfitRate = report.ProfitRate;
|
||||
snapshot.KpiComparisonJson = JsonSerializer.Serialize(report.Kpis, JsonOptions);
|
||||
snapshot.IncomeBreakdownJson = JsonSerializer.Serialize(report.IncomeBreakdowns, JsonOptions);
|
||||
snapshot.CostBreakdownJson = JsonSerializer.Serialize(report.CostBreakdowns, JsonOptions);
|
||||
snapshot.Status = FinanceBusinessReportStatus.Succeeded;
|
||||
snapshot.FinishedAt = DateTime.UtcNow;
|
||||
snapshot.LastError = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
snapshot.Status = FinanceBusinessReportStatus.Failed;
|
||||
snapshot.FinishedAt = DateTime.UtcNow;
|
||||
snapshot.RetryCount += 1;
|
||||
snapshot.LastError = ex.Message[..Math.Min(1024, ex.Message.Length)];
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<FinanceBusinessReportPendingSnapshot>> QueryPendingAsync(int take, CancellationToken cancellationToken)
|
||||
{
|
||||
return await context.FinanceBusinessReportSnapshots
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.DeletedAt == null &&
|
||||
(item.Status == FinanceBusinessReportStatus.Queued
|
||||
|| (item.Status == FinanceBusinessReportStatus.Failed && item.RetryCount < 3)))
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ThenBy(item => item.Id)
|
||||
.Take(take)
|
||||
.Select(item => new FinanceBusinessReportPendingSnapshot
|
||||
{
|
||||
SnapshotId = item.Id,
|
||||
TenantId = item.TenantId
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<ComputedReportSnapshot> BuildComputedSnapshotAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
FinanceBusinessReportPeriodType periodType,
|
||||
DateTime startAt,
|
||||
DateTime endAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await BuildRawMetricsAsync(tenantId, storeId, startAt, endAt, cancellationToken);
|
||||
var previous = ResolvePreviousPeriod(periodType, startAt, endAt);
|
||||
var yearAgo = (startAt.AddYears(-1), endAt.AddYears(-1));
|
||||
var mom = await BuildRawMetricsAsync(tenantId, storeId, previous.StartAt, previous.EndAt, cancellationToken);
|
||||
var yoy = await BuildRawMetricsAsync(tenantId, storeId, yearAgo.Item1, yearAgo.Item2, cancellationToken);
|
||||
|
||||
return current with
|
||||
{
|
||||
Kpis = BuildKpis(current, mom, yoy)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ComputedReportSnapshot> BuildRawMetricsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime startAt,
|
||||
DateTime endAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var summary = await QueryRevenueSummaryAsync(tenantId, storeId, startAt, endAt, cancellationToken);
|
||||
var averageOrderValue = summary.OrderCount <= 0 ? 0m : RoundMoney(summary.RevenueAmount / summary.OrderCount);
|
||||
var refundRate = summary.OrderCount <= 0 ? 0m : RoundRatio((decimal)summary.RefundOrderCount / summary.OrderCount);
|
||||
var incomeBreakdowns = await QueryIncomeBreakdownsAsync(tenantId, storeId, startAt, endAt, summary.RevenueAmount, cancellationToken);
|
||||
var dailyRevenueMap = await QueryDailyRevenueMapAsync(tenantId, storeId, startAt, endAt, cancellationToken);
|
||||
var costBreakdowns = await BuildCostBreakdownsAsync(tenantId, storeId, startAt, endAt, dailyRevenueMap, cancellationToken);
|
||||
var costTotalAmount = RoundMoney(costBreakdowns.Sum(item => item.Amount));
|
||||
var netProfitAmount = RoundMoney(summary.RevenueAmount - costTotalAmount);
|
||||
var profitRate = summary.RevenueAmount <= 0 ? 0m : RoundRatio(netProfitAmount / summary.RevenueAmount);
|
||||
|
||||
return new ComputedReportSnapshot
|
||||
{
|
||||
RevenueAmount = summary.RevenueAmount,
|
||||
OrderCount = summary.OrderCount,
|
||||
AverageOrderValue = averageOrderValue,
|
||||
RefundRate = refundRate,
|
||||
CostTotalAmount = costTotalAmount,
|
||||
NetProfitAmount = netProfitAmount,
|
||||
ProfitRate = profitRate,
|
||||
Kpis = [],
|
||||
IncomeBreakdowns = incomeBreakdowns,
|
||||
CostBreakdowns = costBreakdowns
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(decimal RevenueAmount, int OrderCount, int RefundOrderCount)> QueryRevenueSummaryAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime startAt,
|
||||
DateTime endAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var paidBaseQuery =
|
||||
from payment in context.PaymentRecords.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on payment.OrderId equals order.Id
|
||||
where payment.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& order.StoreId == storeId
|
||||
&& payment.Status == PaymentStatus.Paid
|
||||
&& (payment.PaidAt ?? payment.CreatedAt) >= startAt
|
||||
&& (payment.PaidAt ?? payment.CreatedAt) < endAt
|
||||
select new { payment.Amount, payment.OrderId };
|
||||
|
||||
var paidAmount = await paidBaseQuery.Select(item => item.Amount).DefaultIfEmpty(0m).SumAsync(cancellationToken);
|
||||
var orderCount = await paidBaseQuery.Select(item => item.OrderId).Distinct().CountAsync(cancellationToken);
|
||||
|
||||
var refundBaseQuery =
|
||||
from refund in context.PaymentRefundRecords.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on refund.OrderId equals order.Id
|
||||
where refund.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& order.StoreId == storeId
|
||||
&& refund.Status == PaymentRefundStatus.Succeeded
|
||||
&& (refund.CompletedAt ?? refund.RequestedAt) >= startAt
|
||||
&& (refund.CompletedAt ?? refund.RequestedAt) < endAt
|
||||
select new { refund.Amount, refund.OrderId };
|
||||
|
||||
var refundAmount = await refundBaseQuery.Select(item => item.Amount).DefaultIfEmpty(0m).SumAsync(cancellationToken);
|
||||
var refundOrderCount = await refundBaseQuery.Select(item => item.OrderId).Distinct().CountAsync(cancellationToken);
|
||||
return (RoundMoney(paidAmount - refundAmount), orderCount, refundOrderCount);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<DateTime, decimal>> QueryDailyRevenueMapAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime startAt,
|
||||
DateTime endAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var paidRows = await (
|
||||
from payment in context.PaymentRecords.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on payment.OrderId equals order.Id
|
||||
where payment.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& order.StoreId == storeId
|
||||
&& payment.Status == PaymentStatus.Paid
|
||||
&& (payment.PaidAt ?? payment.CreatedAt) >= startAt
|
||||
&& (payment.PaidAt ?? payment.CreatedAt) < endAt
|
||||
group payment by (payment.PaidAt ?? payment.CreatedAt).Date into grouped
|
||||
select new { BusinessDate = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var refundRows = await (
|
||||
from refund in context.PaymentRefundRecords.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on refund.OrderId equals order.Id
|
||||
where refund.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& order.StoreId == storeId
|
||||
&& refund.Status == PaymentRefundStatus.Succeeded
|
||||
&& (refund.CompletedAt ?? refund.RequestedAt) >= startAt
|
||||
&& (refund.CompletedAt ?? refund.RequestedAt) < endAt
|
||||
group refund by (refund.CompletedAt ?? refund.RequestedAt).Date into grouped
|
||||
select new { BusinessDate = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var map = new Dictionary<DateTime, decimal>();
|
||||
foreach (var row in paidRows)
|
||||
{
|
||||
var date = ToUtcDate(row.BusinessDate);
|
||||
map[date] = map.GetValueOrDefault(date, 0m) + row.Amount;
|
||||
}
|
||||
|
||||
foreach (var row in refundRows)
|
||||
{
|
||||
var date = ToUtcDate(row.BusinessDate);
|
||||
map[date] = map.GetValueOrDefault(date, 0m) - row.Amount;
|
||||
}
|
||||
|
||||
return map.ToDictionary(item => item.Key, item => RoundMoney(item.Value));
|
||||
}
|
||||
|
||||
private async Task<List<FinanceBusinessReportBreakdownSnapshot>> QueryIncomeBreakdownsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime startAt,
|
||||
DateTime endAt,
|
||||
decimal totalRevenue,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var paidRows = await (
|
||||
from payment in context.PaymentRecords.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on payment.OrderId equals order.Id
|
||||
where payment.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& order.StoreId == storeId
|
||||
&& payment.Status == PaymentStatus.Paid
|
||||
&& (payment.PaidAt ?? payment.CreatedAt) >= startAt
|
||||
&& (payment.PaidAt ?? payment.CreatedAt) < endAt
|
||||
group payment by order.DeliveryType into grouped
|
||||
select new { DeliveryType = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
|
||||
.ToListAsync(cancellationToken);
|
||||
var refundRows = await (
|
||||
from refund in context.PaymentRefundRecords.AsNoTracking()
|
||||
join order in context.Orders.AsNoTracking()
|
||||
on refund.OrderId equals order.Id
|
||||
where refund.TenantId == tenantId
|
||||
&& order.TenantId == tenantId
|
||||
&& order.StoreId == storeId
|
||||
&& refund.Status == PaymentRefundStatus.Succeeded
|
||||
&& (refund.CompletedAt ?? refund.RequestedAt) >= startAt
|
||||
&& (refund.CompletedAt ?? refund.RequestedAt) < endAt
|
||||
group refund by order.DeliveryType into grouped
|
||||
select new { DeliveryType = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
|
||||
.ToListAsync(cancellationToken);
|
||||
var paidMap = paidRows.ToDictionary(item => item.DeliveryType, item => item.Amount);
|
||||
var refundMap = refundRows.ToDictionary(item => item.DeliveryType, item => item.Amount);
|
||||
|
||||
return IncomeChannelOrder.Select(channel =>
|
||||
{
|
||||
var amount = paidMap.GetValueOrDefault(channel, 0m) - refundMap.GetValueOrDefault(channel, 0m);
|
||||
return new FinanceBusinessReportBreakdownSnapshot
|
||||
{
|
||||
Key = channel switch
|
||||
{
|
||||
DeliveryType.Delivery => "delivery",
|
||||
DeliveryType.Pickup => "pickup",
|
||||
DeliveryType.DineIn => "dine_in",
|
||||
_ => "delivery"
|
||||
},
|
||||
Label = channel switch
|
||||
{
|
||||
DeliveryType.Delivery => "外卖",
|
||||
DeliveryType.Pickup => "自提",
|
||||
DeliveryType.DineIn => "堂食",
|
||||
_ => "外卖"
|
||||
},
|
||||
Amount = RoundMoney(amount),
|
||||
Ratio = totalRevenue <= 0 ? 0m : RoundRatio(amount / totalRevenue)
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<FinanceBusinessReportBreakdownSnapshot>> BuildCostBreakdownsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
DateTime startAt,
|
||||
DateTime endAt,
|
||||
IReadOnlyDictionary<DateTime, decimal> dailyRevenueMap,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var profiles = await context.FinanceCostProfiles.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null && item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder).ThenByDescending(item => item.EffectiveFrom).ToListAsync(cancellationToken);
|
||||
var overrides = await context.FinanceCostDailyOverrides.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null && item.BusinessDate >= startAt.Date && item.BusinessDate < endAt.Date)
|
||||
.ToListAsync(cancellationToken);
|
||||
var overrideMap = overrides.ToDictionary(item => $"{item.BusinessDate:yyyyMMdd}:{(int)item.Category}", item => item.Amount);
|
||||
var categoryAmountMap = CostCategoryOrder.ToDictionary(item => item, _ => 0m);
|
||||
|
||||
for (var businessDay = startAt.Date; businessDay < endAt.Date; businessDay = businessDay.AddDays(1))
|
||||
{
|
||||
var dayRevenue = dailyRevenueMap.GetValueOrDefault(ToUtcDate(businessDay), 0m);
|
||||
foreach (var category in CostCategoryOrder)
|
||||
{
|
||||
var key = $"{businessDay:yyyyMMdd}:{(int)category}";
|
||||
decimal amount;
|
||||
if (overrideMap.TryGetValue(key, out var overrideAmount))
|
||||
{
|
||||
amount = overrideAmount;
|
||||
}
|
||||
else
|
||||
{
|
||||
var profile = profiles.FirstOrDefault(item =>
|
||||
item.Category == category &&
|
||||
item.EffectiveFrom.Date <= businessDay &&
|
||||
(!item.EffectiveTo.HasValue || item.EffectiveTo.Value.Date >= businessDay));
|
||||
var defaults = DefaultCostProfileMap[category];
|
||||
var mode = profile?.CalcMode ?? defaults.Mode;
|
||||
var ratio = profile?.Ratio ?? defaults.Ratio;
|
||||
var fixedDaily = profile?.FixedDailyAmount ?? defaults.Fixed;
|
||||
amount = mode == FinanceCostCalcMode.FixedDaily ? fixedDaily : dayRevenue * Math.Max(0m, ratio);
|
||||
}
|
||||
|
||||
categoryAmountMap[category] += RoundMoney(amount);
|
||||
}
|
||||
}
|
||||
|
||||
var totalCostAmount = categoryAmountMap.Sum(item => item.Value);
|
||||
return CostCategoryOrder.Select(category => new FinanceBusinessReportBreakdownSnapshot
|
||||
{
|
||||
Key = category switch
|
||||
{
|
||||
FinanceCostCategory.FoodMaterial => "food_material",
|
||||
FinanceCostCategory.Labor => "labor",
|
||||
FinanceCostCategory.FixedExpense => "fixed_expense",
|
||||
FinanceCostCategory.PackagingConsumable => "packaging_consumable",
|
||||
_ => "food_material"
|
||||
},
|
||||
Label = category switch
|
||||
{
|
||||
FinanceCostCategory.FoodMaterial => "食材成本",
|
||||
FinanceCostCategory.Labor => "人工成本",
|
||||
FinanceCostCategory.FixedExpense => "固定成本",
|
||||
FinanceCostCategory.PackagingConsumable => "包装成本",
|
||||
_ => "食材成本"
|
||||
},
|
||||
Amount = RoundMoney(categoryAmountMap[category]),
|
||||
Ratio = totalCostAmount <= 0m ? 0m : RoundRatio(categoryAmountMap[category] / totalCostAmount)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static List<FinanceBusinessReportKpiSnapshot> BuildKpis(ComputedReportSnapshot current, ComputedReportSnapshot mom, ComputedReportSnapshot yoy)
|
||||
{
|
||||
var definitions = new List<(string Key, string Label, decimal Current, decimal PrevMom, decimal PrevYoy)>
|
||||
{
|
||||
("revenue", "营业额", current.RevenueAmount, mom.RevenueAmount, yoy.RevenueAmount),
|
||||
("order_count", "订单数", current.OrderCount, mom.OrderCount, yoy.OrderCount),
|
||||
("average_order_value", "客单价", current.AverageOrderValue, mom.AverageOrderValue, yoy.AverageOrderValue),
|
||||
("refund_rate", "退款率", current.RefundRate, mom.RefundRate, yoy.RefundRate),
|
||||
("net_profit", "净利润", current.NetProfitAmount, mom.NetProfitAmount, yoy.NetProfitAmount),
|
||||
("profit_rate", "利润率", current.ProfitRate, mom.ProfitRate, yoy.ProfitRate)
|
||||
};
|
||||
|
||||
return definitions.Select(item => new FinanceBusinessReportKpiSnapshot
|
||||
{
|
||||
Key = item.Key,
|
||||
Label = item.Label,
|
||||
Value = item.Current,
|
||||
MomChangeRate = CalculateChangeRate(item.Current, item.PrevMom),
|
||||
YoyChangeRate = CalculateChangeRate(item.Current, item.PrevYoy)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static (DateTime StartAt, DateTime EndAt) ResolvePreviousPeriod(FinanceBusinessReportPeriodType periodType, DateTime startAt, DateTime endAt)
|
||||
{
|
||||
return periodType switch
|
||||
{
|
||||
FinanceBusinessReportPeriodType.Daily => (startAt.AddDays(-1), endAt.AddDays(-1)),
|
||||
FinanceBusinessReportPeriodType.Weekly => (startAt.AddDays(-7), endAt.AddDays(-7)),
|
||||
FinanceBusinessReportPeriodType.Monthly => (startAt.AddMonths(-1), endAt.AddMonths(-1)),
|
||||
_ => (startAt.AddDays(-1), endAt.AddDays(-1))
|
||||
};
|
||||
}
|
||||
|
||||
private static List<(DateTime StartAt, DateTime EndAt)> BuildPagedPeriods(FinanceBusinessReportPeriodType periodType, int page, int pageSize, DateTime now)
|
||||
{
|
||||
var normalizedPage = Math.Max(1, page);
|
||||
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
|
||||
var offsetStart = (normalizedPage - 1) * normalizedPageSize;
|
||||
var today = ToUtcDate(now);
|
||||
var list = new List<(DateTime StartAt, DateTime EndAt)>(normalizedPageSize);
|
||||
for (var index = 0; index < normalizedPageSize; index++)
|
||||
{
|
||||
var offset = offsetStart + index;
|
||||
if (periodType == FinanceBusinessReportPeriodType.Weekly)
|
||||
{
|
||||
var weekStart = GetWeekStart(today).AddDays(-7 * offset);
|
||||
list.Add((weekStart, weekStart.AddDays(7)));
|
||||
}
|
||||
else if (periodType == FinanceBusinessReportPeriodType.Monthly)
|
||||
{
|
||||
var monthStart = new DateTime(today.Year, today.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-offset);
|
||||
list.Add((monthStart, monthStart.AddMonths(1)));
|
||||
}
|
||||
else
|
||||
{
|
||||
var dayStart = today.AddDays(-offset);
|
||||
list.Add((dayStart, dayStart.AddDays(1)));
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static List<TItem> Deserialize<TItem>(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<TItem>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ComputedReportSnapshot
|
||||
{
|
||||
public decimal RevenueAmount { get; init; }
|
||||
|
||||
public int OrderCount { get; init; }
|
||||
|
||||
public decimal AverageOrderValue { get; init; }
|
||||
|
||||
public decimal RefundRate { get; init; }
|
||||
|
||||
public decimal CostTotalAmount { get; init; }
|
||||
|
||||
public decimal NetProfitAmount { get; init; }
|
||||
|
||||
public decimal ProfitRate { get; init; }
|
||||
|
||||
public List<FinanceBusinessReportKpiSnapshot> Kpis { get; init; } = [];
|
||||
|
||||
public List<FinanceBusinessReportBreakdownSnapshot> IncomeBreakdowns { get; init; } = [];
|
||||
|
||||
public List<FinanceBusinessReportBreakdownSnapshot> CostBreakdowns { get; init; } = [];
|
||||
}
|
||||
|
||||
private static decimal CalculateChangeRate(decimal currentValue, decimal previousValue) => previousValue <= 0m ? (currentValue <= 0m ? 0m : 100m) : RoundRate((currentValue - previousValue) / previousValue * 100m);
|
||||
private static decimal RoundMoney(decimal value) => decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
private static decimal RoundRate(decimal value) => decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
private static decimal RoundRatio(decimal value) => decimal.Round(value, 4, MidpointRounding.AwayFromZero);
|
||||
private static DateTime ToUtcDate(DateTime value) => new(value.Year, value.Month, value.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||
private static DateTime GetWeekStart(DateTime date) => date.AddDays(0 - (((int)date.DayOfWeek + 6) % 7));
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表导出服务实现(PDF / Excel)。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportExportService : IFinanceBusinessReportExportService
|
||||
{
|
||||
public FinanceBusinessReportExportService()
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> ExportSinglePdfAsync(
|
||||
FinanceBusinessReportDetailSnapshot detail,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(detail);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(BuildPdf([detail]));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> ExportSingleExcelAsync(
|
||||
FinanceBusinessReportDetailSnapshot detail,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(detail);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
using var workbook = new XLWorkbook();
|
||||
var worksheet = workbook.Worksheets.Add("经营报表");
|
||||
WriteDetailWorksheet(worksheet, detail);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return Task.FromResult(stream.ToArray());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> ExportBatchPdfAsync(
|
||||
IReadOnlyList<FinanceBusinessReportDetailSnapshot> details,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(details);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(BuildPdf(details));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> ExportBatchExcelAsync(
|
||||
IReadOnlyList<FinanceBusinessReportDetailSnapshot> details,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(details);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
using var workbook = new XLWorkbook();
|
||||
var summary = workbook.Worksheets.Add("汇总");
|
||||
WriteSummaryWorksheet(summary, details);
|
||||
|
||||
for (var index = 0; index < details.Count; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var detail = details[index];
|
||||
var sheetName = $"报表{index + 1:D2}";
|
||||
var worksheet = workbook.Worksheets.Add(sheetName);
|
||||
WriteDetailWorksheet(worksheet, detail);
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return Task.FromResult(stream.ToArray());
|
||||
}
|
||||
|
||||
private static byte[] BuildPdf(IReadOnlyList<FinanceBusinessReportDetailSnapshot> details)
|
||||
{
|
||||
var source = details.Count == 0
|
||||
? [new FinanceBusinessReportDetailSnapshot()]
|
||||
: details;
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
foreach (var detail in source)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(24);
|
||||
page.DefaultTextStyle(x => x.FontSize(10));
|
||||
|
||||
page.Content().Column(column =>
|
||||
{
|
||||
column.Spacing(8);
|
||||
column.Item().Text(BuildTitle(detail)).FontSize(16).SemiBold();
|
||||
column.Item().Text($"状态:{ResolveStatusText(detail.Status)}");
|
||||
|
||||
column.Item().Element(section => BuildKpiSection(section, detail.Kpis));
|
||||
column.Item().Element(section => BuildBreakdownSection(section, "收入明细(按渠道)", detail.IncomeBreakdowns));
|
||||
column.Item().Element(section => BuildBreakdownSection(section, "成本明细(按类别)", detail.CostBreakdowns));
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return document.GeneratePdf();
|
||||
}
|
||||
|
||||
private static void BuildKpiSection(IContainer container, IReadOnlyList<FinanceBusinessReportKpiSnapshot> kpis)
|
||||
{
|
||||
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
|
||||
{
|
||||
column.Spacing(4);
|
||||
column.Item().Text("关键指标").SemiBold();
|
||||
|
||||
if (kpis.Count == 0)
|
||||
{
|
||||
column.Item().Text("暂无数据");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in kpis)
|
||||
{
|
||||
column.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Text(item.Label);
|
||||
row.RelativeItem().AlignRight().Text(FormatKpiValue(item.Key, item.Value));
|
||||
row.RelativeItem().AlignRight().Text(
|
||||
$"同比 {FormatSignedRate(item.YoyChangeRate)} | 环比 {FormatSignedRate(item.MomChangeRate)}");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void BuildBreakdownSection(
|
||||
IContainer container,
|
||||
string title,
|
||||
IReadOnlyList<FinanceBusinessReportBreakdownSnapshot> rows)
|
||||
{
|
||||
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
|
||||
{
|
||||
column.Spacing(4);
|
||||
column.Item().Text(title).SemiBold();
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
column.Item().Text("暂无数据");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in rows)
|
||||
{
|
||||
column.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Text(item.Label);
|
||||
row.ConstantItem(80).AlignRight().Text(FormatPercent(item.Ratio));
|
||||
row.ConstantItem(120).AlignRight().Text(FormatCurrency(item.Amount));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void WriteSummaryWorksheet(
|
||||
IXLWorksheet worksheet,
|
||||
IReadOnlyList<FinanceBusinessReportDetailSnapshot> details)
|
||||
{
|
||||
worksheet.Cell(1, 1).Value = "报表标题";
|
||||
worksheet.Cell(1, 2).Value = "状态";
|
||||
worksheet.Cell(1, 3).Value = "营业额";
|
||||
worksheet.Cell(1, 4).Value = "订单数";
|
||||
worksheet.Cell(1, 5).Value = "客单价";
|
||||
worksheet.Cell(1, 6).Value = "退款率";
|
||||
worksheet.Cell(1, 7).Value = "成本总额";
|
||||
worksheet.Cell(1, 8).Value = "净利润";
|
||||
worksheet.Cell(1, 9).Value = "利润率";
|
||||
|
||||
for (var index = 0; index < details.Count; index++)
|
||||
{
|
||||
var row = index + 2;
|
||||
var detail = details[index];
|
||||
worksheet.Cell(row, 1).Value = BuildTitle(detail);
|
||||
worksheet.Cell(row, 2).Value = ResolveStatusText(detail.Status);
|
||||
worksheet.Cell(row, 3).Value = detail.RevenueAmount;
|
||||
worksheet.Cell(row, 4).Value = detail.OrderCount;
|
||||
worksheet.Cell(row, 5).Value = detail.AverageOrderValue;
|
||||
worksheet.Cell(row, 6).Value = detail.RefundRate;
|
||||
worksheet.Cell(row, 7).Value = detail.CostTotalAmount;
|
||||
worksheet.Cell(row, 8).Value = detail.NetProfitAmount;
|
||||
worksheet.Cell(row, 9).Value = detail.ProfitRate;
|
||||
}
|
||||
|
||||
worksheet.Columns().AdjustToContents();
|
||||
}
|
||||
|
||||
private static void WriteDetailWorksheet(
|
||||
IXLWorksheet worksheet,
|
||||
FinanceBusinessReportDetailSnapshot detail)
|
||||
{
|
||||
var row = 1;
|
||||
|
||||
worksheet.Cell(row, 1).Value = BuildTitle(detail);
|
||||
worksheet.Range(row, 1, row, 4).Merge().Style.Font.SetBold();
|
||||
row += 2;
|
||||
|
||||
worksheet.Cell(row, 1).Value = "关键指标";
|
||||
worksheet.Cell(row, 1).Style.Font.SetBold();
|
||||
row += 1;
|
||||
|
||||
worksheet.Cell(row, 1).Value = "指标";
|
||||
worksheet.Cell(row, 2).Value = "值";
|
||||
worksheet.Cell(row, 3).Value = "同比";
|
||||
worksheet.Cell(row, 4).Value = "环比";
|
||||
row += 1;
|
||||
|
||||
foreach (var item in detail.Kpis)
|
||||
{
|
||||
worksheet.Cell(row, 1).Value = item.Label;
|
||||
worksheet.Cell(row, 2).Value = FormatKpiValue(item.Key, item.Value);
|
||||
worksheet.Cell(row, 3).Value = FormatSignedRate(item.YoyChangeRate);
|
||||
worksheet.Cell(row, 4).Value = FormatSignedRate(item.MomChangeRate);
|
||||
row += 1;
|
||||
}
|
||||
|
||||
row += 1;
|
||||
row = WriteBreakdownTable(worksheet, row, "收入明细(按渠道)", detail.IncomeBreakdowns);
|
||||
row += 1;
|
||||
_ = WriteBreakdownTable(worksheet, row, "成本明细(按类别)", detail.CostBreakdowns);
|
||||
|
||||
worksheet.Columns().AdjustToContents();
|
||||
}
|
||||
|
||||
private static int WriteBreakdownTable(
|
||||
IXLWorksheet worksheet,
|
||||
int startRow,
|
||||
string title,
|
||||
IReadOnlyList<FinanceBusinessReportBreakdownSnapshot> rows)
|
||||
{
|
||||
var row = startRow;
|
||||
worksheet.Cell(row, 1).Value = title;
|
||||
worksheet.Cell(row, 1).Style.Font.SetBold();
|
||||
row += 1;
|
||||
|
||||
worksheet.Cell(row, 1).Value = "名称";
|
||||
worksheet.Cell(row, 2).Value = "占比";
|
||||
worksheet.Cell(row, 3).Value = "金额";
|
||||
row += 1;
|
||||
|
||||
foreach (var item in rows)
|
||||
{
|
||||
worksheet.Cell(row, 1).Value = item.Label;
|
||||
worksheet.Cell(row, 2).Value = FormatPercent(item.Ratio);
|
||||
worksheet.Cell(row, 3).Value = item.Amount;
|
||||
row += 1;
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private static string FormatCurrency(decimal value) => $"¥{value:0.##}";
|
||||
private static string FormatPercent(decimal ratioValue) => $"{ratioValue * 100m:0.##}%";
|
||||
private static string FormatSignedRate(decimal rate) => $"{(rate >= 0m ? "+" : string.Empty)}{rate:0.##}%";
|
||||
private static string BuildTitle(FinanceBusinessReportDetailSnapshot detail) => detail.PeriodType switch
|
||||
{
|
||||
FinanceBusinessReportPeriodType.Daily => $"{detail.PeriodStartAt:yyyy年M月d日} 经营日报",
|
||||
FinanceBusinessReportPeriodType.Weekly => $"{detail.PeriodStartAt:yyyy年M月d日}~{detail.PeriodEndAt.AddDays(-1):M月d日} 经营周报",
|
||||
FinanceBusinessReportPeriodType.Monthly => $"{detail.PeriodStartAt:yyyy年M月} 经营月报",
|
||||
_ => detail.PeriodStartAt == default
|
||||
? "经营报表"
|
||||
: detail.PeriodStartAt.ToString("yyyy-MM-dd 经营报表", CultureInfo.InvariantCulture)
|
||||
};
|
||||
private static string ResolveStatusText(FinanceBusinessReportStatus status) => status switch
|
||||
{
|
||||
FinanceBusinessReportStatus.Queued => "排队中",
|
||||
FinanceBusinessReportStatus.Running => "生成中",
|
||||
FinanceBusinessReportStatus.Succeeded => "已生成",
|
||||
FinanceBusinessReportStatus.Failed => "生成失败",
|
||||
_ => "未知"
|
||||
};
|
||||
|
||||
private static string FormatKpiValue(string key, decimal value)
|
||||
{
|
||||
if (key == "order_count")
|
||||
{
|
||||
return $"{decimal.Round(value, 0, MidpointRounding.AwayFromZero):0}";
|
||||
}
|
||||
|
||||
if (key is "refund_rate" or "profit_rate")
|
||||
{
|
||||
return $"{value * 100m:0.##}%";
|
||||
}
|
||||
|
||||
return FormatCurrency(value);
|
||||
}
|
||||
}
|
||||
10972
src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304071623_AddFinanceInvoiceModule.Designer.cs
generated
Normal file
10972
src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304071623_AddFinanceInvoiceModule.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFinanceInvoiceModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "finance_cost_entries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Dimension = table.Column<int>(type: "integer", nullable: false, comment: "统计维度。"),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识(租户汇总维度为空)。"),
|
||||
CostMonth = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "成本月份(统一存储为 UTC 每月第一天 00:00:00)。"),
|
||||
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
|
||||
TotalAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "分类总金额。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_finance_cost_entries", x => x.Id);
|
||||
},
|
||||
comment: "成本录入月度汇总实体(按维度 + 分类)。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "finance_invoice_records",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
InvoiceNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "发票号码。"),
|
||||
ApplicantName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "申请人。"),
|
||||
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "开票抬头(公司名)。"),
|
||||
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "纳税人识别号快照。"),
|
||||
InvoiceType = table.Column<int>(type: "integer", nullable: false, comment: "发票类型。"),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "开票金额。"),
|
||||
OrderNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "关联订单号。"),
|
||||
ContactEmail = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "接收邮箱。"),
|
||||
ContactPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "联系电话。"),
|
||||
ApplyRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "申请备注。"),
|
||||
Status = table.Column<int>(type: "integer", nullable: false, comment: "发票状态。"),
|
||||
AppliedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "申请时间(UTC)。"),
|
||||
IssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "开票时间(UTC)。"),
|
||||
IssuedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "开票人 ID。"),
|
||||
IssueRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "开票备注。"),
|
||||
VoidedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "作废时间(UTC)。"),
|
||||
VoidedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "作废人 ID。"),
|
||||
VoidReason = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "作废原因。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_finance_invoice_records", x => x.Id);
|
||||
},
|
||||
comment: "租户发票记录。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "finance_invoice_settings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "企业名称。"),
|
||||
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "纳税人识别号。"),
|
||||
RegisteredAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "注册地址。"),
|
||||
RegisteredPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "注册电话。"),
|
||||
BankName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "开户银行。"),
|
||||
BankAccount = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "银行账号。"),
|
||||
EnableElectronicNormalInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子普通发票。"),
|
||||
EnableElectronicSpecialInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子专用发票。"),
|
||||
EnableAutoIssue = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用自动开票。"),
|
||||
AutoIssueMaxAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "自动开票单张最大金额。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_finance_invoice_settings", x => x.Id);
|
||||
},
|
||||
comment: "租户发票开票基础设置。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "finance_cost_entry_items",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
EntryId = table.Column<long>(type: "bigint", nullable: false, comment: "关联汇总行标识。"),
|
||||
Dimension = table.Column<int>(type: "integer", nullable: false, comment: "统计维度。"),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: true, comment: "门店标识(租户汇总维度为空)。"),
|
||||
CostMonth = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "成本月份(统一存储为 UTC 每月第一天 00:00:00)。"),
|
||||
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
|
||||
ItemName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "明细名称。"),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "明细金额。"),
|
||||
Quantity = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "数量(人工类可用)。"),
|
||||
UnitPrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "单价(人工类可用)。"),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 100, comment: "排序值。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_finance_cost_entry_items", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_finance_cost_entry_items_finance_cost_entries_EntryId",
|
||||
column: x => x.EntryId,
|
||||
principalTable: "finance_cost_entries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
},
|
||||
comment: "成本录入明细项实体。");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_cost_entries_TenantId_Dimension_StoreId_CostMonth",
|
||||
table: "finance_cost_entries",
|
||||
columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_cost_entries_TenantId_Dimension_StoreId_CostMonth_C~",
|
||||
table: "finance_cost_entries",
|
||||
columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth", "Category" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_cost_entry_items_EntryId",
|
||||
table: "finance_cost_entry_items",
|
||||
column: "EntryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_cost_entry_items_TenantId_Dimension_StoreId_CostMon~",
|
||||
table: "finance_cost_entry_items",
|
||||
columns: new[] { "TenantId", "Dimension", "StoreId", "CostMonth", "Category", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_invoice_records_TenantId_InvoiceNo",
|
||||
table: "finance_invoice_records",
|
||||
columns: new[] { "TenantId", "InvoiceNo" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_invoice_records_TenantId_InvoiceType_AppliedAt",
|
||||
table: "finance_invoice_records",
|
||||
columns: new[] { "TenantId", "InvoiceType", "AppliedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_invoice_records_TenantId_OrderNo",
|
||||
table: "finance_invoice_records",
|
||||
columns: new[] { "TenantId", "OrderNo" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_invoice_records_TenantId_Status_AppliedAt",
|
||||
table: "finance_invoice_records",
|
||||
columns: new[] { "TenantId", "Status", "AppliedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_invoice_records_TenantId_Status_IssuedAt",
|
||||
table: "finance_invoice_records",
|
||||
columns: new[] { "TenantId", "Status", "IssuedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_invoice_settings_TenantId",
|
||||
table: "finance_invoice_settings",
|
||||
column: "TenantId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "finance_cost_entry_items");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "finance_invoice_records");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "finance_invoice_settings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "finance_cost_entries");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// 新增经营报表快照与成本配置表结构。
|
||||
/// </summary>
|
||||
[DbContext(typeof(TakeoutAppDbContext))]
|
||||
[Migration("20260305090000_AddFinanceBusinessReportModule")]
|
||||
public sealed class AddFinanceBusinessReportModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "finance_business_report_snapshots",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "所属门店 ID。"),
|
||||
PeriodType = table.Column<int>(type: "integer", nullable: false, comment: "周期类型。"),
|
||||
PeriodStartAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "周期开始时间(UTC,含)。"),
|
||||
PeriodEndAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "周期结束时间(UTC,不含)。"),
|
||||
Status = table.Column<int>(type: "integer", nullable: false, comment: "生成状态。"),
|
||||
RevenueAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "营业额。"),
|
||||
OrderCount = table.Column<int>(type: "integer", nullable: false, comment: "订单数。"),
|
||||
AverageOrderValue = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "客单价。"),
|
||||
RefundRate = table.Column<decimal>(type: "numeric(9,4)", precision: 9, scale: 4, nullable: false, comment: "退款率(0-1)。"),
|
||||
CostTotalAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "成本总额。"),
|
||||
NetProfitAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "净利润。"),
|
||||
ProfitRate = table.Column<decimal>(type: "numeric(9,4)", precision: 9, scale: 4, nullable: false, comment: "利润率(0-1)。"),
|
||||
KpiComparisonJson = table.Column<string>(type: "text", nullable: false, comment: "KPI 比较快照 JSON(同比/环比)。"),
|
||||
IncomeBreakdownJson = table.Column<string>(type: "text", nullable: false, comment: "收入明细快照 JSON(按渠道)。"),
|
||||
CostBreakdownJson = table.Column<string>(type: "text", nullable: false, comment: "成本明细快照 JSON(按类别)。"),
|
||||
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "生成开始时间(UTC)。"),
|
||||
FinishedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "生成完成时间(UTC)。"),
|
||||
LastError = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true, comment: "最近一次失败信息。"),
|
||||
RetryCount = table.Column<int>(type: "integer", nullable: false, defaultValue: 0, comment: "重试次数。"),
|
||||
HangfireJobId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "调度任务 ID。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_finance_business_report_snapshots", x => x.Id);
|
||||
},
|
||||
comment: "经营报表快照实体。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "finance_cost_profiles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
|
||||
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
|
||||
CalcMode = table.Column<int>(type: "integer", nullable: false, comment: "计算模式。"),
|
||||
Ratio = table.Column<decimal>(type: "numeric(9,6)", precision: 9, scale: 6, nullable: false, comment: "比例值(0-1)。"),
|
||||
FixedDailyAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "固定日金额。"),
|
||||
EffectiveFrom = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "生效开始日期(UTC 日期)。"),
|
||||
EffectiveTo = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "生效结束日期(UTC 日期,含)。"),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用。"),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 100, comment: "排序值。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_finance_cost_profiles", x => x.Id);
|
||||
},
|
||||
comment: "成本配置实体(类别级规则)。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "finance_cost_daily_overrides",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
|
||||
BusinessDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "业务日期(UTC 日期)。"),
|
||||
Category = table.Column<int>(type: "integer", nullable: false, comment: "成本分类。"),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "覆盖金额。"),
|
||||
Remark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "备注。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_finance_cost_daily_overrides", x => x.Id);
|
||||
},
|
||||
comment: "成本日覆盖实体。");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_business_report_snapshots_TenantId_Status_CreatedAt",
|
||||
table: "finance_business_report_snapshots",
|
||||
columns: new[] { "TenantId", "Status", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_business_report_snapshots_TenantId_StoreId_PeriodType_S~",
|
||||
table: "finance_business_report_snapshots",
|
||||
columns: new[] { "TenantId", "StoreId", "PeriodType", "Status", "PeriodStartAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_business_report_snapshots_TenantId_StoreId_PeriodType_P~",
|
||||
table: "finance_business_report_snapshots",
|
||||
columns: new[] { "TenantId", "StoreId", "PeriodType", "PeriodStartAt" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_cost_daily_overrides_TenantId_StoreId_BusinessDate",
|
||||
table: "finance_cost_daily_overrides",
|
||||
columns: new[] { "TenantId", "StoreId", "BusinessDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_cost_daily_overrides_TenantId_StoreId_BusinessDate_Cate~",
|
||||
table: "finance_cost_daily_overrides",
|
||||
columns: new[] { "TenantId", "StoreId", "BusinessDate", "Category" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_cost_profiles_TenantId_StoreId_Category_EffectiveFrom_E~",
|
||||
table: "finance_cost_profiles",
|
||||
columns: new[] { "TenantId", "StoreId", "Category", "EffectiveFrom", "EffectiveTo" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_finance_cost_profiles_TenantId_StoreId_IsEnabled_SortOrder",
|
||||
table: "finance_cost_profiles",
|
||||
columns: new[] { "TenantId", "StoreId", "IsEnabled", "SortOrder" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "finance_business_report_snapshots");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "finance_cost_daily_overrides");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "finance_cost_profiles");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb;
|
||||
|
||||
/// <summary>
|
||||
/// 写入经营报表菜单与权限定义。
|
||||
/// </summary>
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
[Migration("20260305093000_SeedFinanceReportMenuAndPermissions")]
|
||||
public sealed class SeedFinanceReportMenuAndPermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DO $$
|
||||
DECLARE
|
||||
v_parent_permission_id bigint;
|
||||
v_view_permission_id bigint;
|
||||
v_export_permission_id bigint;
|
||||
v_parent_menu_id bigint;
|
||||
v_report_menu_id bigint;
|
||||
v_permission_seed_base bigint := 840200000000000000;
|
||||
v_menu_seed_base bigint := 850200000000000000;
|
||||
BEGIN
|
||||
-- 1. 确保统计权限分组存在。
|
||||
SELECT "Id"
|
||||
INTO v_parent_permission_id
|
||||
FROM public.permissions
|
||||
WHERE "Code" = 'group:tenant:statistics'
|
||||
ORDER BY "Id"
|
||||
LIMIT 1;
|
||||
|
||||
IF v_parent_permission_id IS NULL THEN
|
||||
v_parent_permission_id := v_permission_seed_base + 1;
|
||||
INSERT INTO public.permissions (
|
||||
"Id", "Name", "Code", "Description",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||
"ParentId", "SortOrder", "Type", "Portal")
|
||||
VALUES (
|
||||
v_parent_permission_id, '经营分析', 'group:tenant:statistics', '经营分析权限分组',
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
0, 5300, 'group', 1)
|
||||
ON CONFLICT ("Code") DO NOTHING;
|
||||
END IF;
|
||||
|
||||
-- 2. Upsert 经营报表查看权限。
|
||||
INSERT INTO public.permissions (
|
||||
"Id", "Name", "Code", "Description",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||
"ParentId", "SortOrder", "Type", "Portal")
|
||||
VALUES (
|
||||
v_permission_seed_base + 11, '经营报表查看', 'tenant:statistics:report:view', '查看经营报表列表与详情',
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
v_parent_permission_id, 5310, 'leaf', 1)
|
||||
ON CONFLICT ("Code") DO UPDATE
|
||||
SET "Name" = EXCLUDED."Name",
|
||||
"Description" = EXCLUDED."Description",
|
||||
"ParentId" = EXCLUDED."ParentId",
|
||||
"SortOrder" = EXCLUDED."SortOrder",
|
||||
"Type" = EXCLUDED."Type",
|
||||
"Portal" = EXCLUDED."Portal",
|
||||
"DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW();
|
||||
|
||||
-- 3. Upsert 经营报表导出权限。
|
||||
INSERT INTO public.permissions (
|
||||
"Id", "Name", "Code", "Description",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy",
|
||||
"ParentId", "SortOrder", "Type", "Portal")
|
||||
VALUES (
|
||||
v_permission_seed_base + 12, '经营报表导出', 'tenant:statistics:report:export', '导出经营报表 PDF / Excel / ZIP',
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
v_parent_permission_id, 5320, 'leaf', 1)
|
||||
ON CONFLICT ("Code") DO UPDATE
|
||||
SET "Name" = EXCLUDED."Name",
|
||||
"Description" = EXCLUDED."Description",
|
||||
"ParentId" = EXCLUDED."ParentId",
|
||||
"SortOrder" = EXCLUDED."SortOrder",
|
||||
"Type" = EXCLUDED."Type",
|
||||
"Portal" = EXCLUDED."Portal",
|
||||
"DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW();
|
||||
|
||||
SELECT "Id" INTO v_view_permission_id
|
||||
FROM public.permissions
|
||||
WHERE "Code" = 'tenant:statistics:report:view'
|
||||
LIMIT 1;
|
||||
|
||||
SELECT "Id" INTO v_export_permission_id
|
||||
FROM public.permissions
|
||||
WHERE "Code" = 'tenant:statistics:report:export'
|
||||
LIMIT 1;
|
||||
|
||||
-- 4. 确保租户端财务父菜单存在。
|
||||
SELECT "Id"
|
||||
INTO v_parent_menu_id
|
||||
FROM public.menu_definitions
|
||||
WHERE "Portal" = 1 AND "Path" = '/finance' AND "DeletedAt" IS NULL
|
||||
ORDER BY "Id"
|
||||
LIMIT 1;
|
||||
|
||||
IF v_parent_menu_id IS NULL THEN
|
||||
v_parent_menu_id := v_menu_seed_base + 1;
|
||||
INSERT INTO public.menu_definitions (
|
||||
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
|
||||
"IsIframe", "Link", "KeepAlive", "SortOrder",
|
||||
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
|
||||
VALUES (
|
||||
v_parent_menu_id, 0, 'Finance', '/finance', 'BasicLayout', '财务中心', 'lucide:wallet',
|
||||
FALSE, NULL, FALSE, 500,
|
||||
'', '', '', NULL,
|
||||
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
END IF;
|
||||
|
||||
-- 5. Upsert 经营报表菜单。
|
||||
SELECT "Id"
|
||||
INTO v_report_menu_id
|
||||
FROM public.menu_definitions
|
||||
WHERE "Portal" = 1
|
||||
AND ("Path" = '/finance/report' OR ("Path" = 'report' AND "Component" = '/finance/report/index'))
|
||||
ORDER BY "DeletedAt" NULLS FIRST, "Id"
|
||||
LIMIT 1;
|
||||
|
||||
IF v_report_menu_id IS NULL THEN
|
||||
v_report_menu_id := v_menu_seed_base + 11;
|
||||
INSERT INTO public.menu_definitions (
|
||||
"Id", "ParentId", "Name", "Path", "Component", "Title", "Icon",
|
||||
"IsIframe", "Link", "KeepAlive", "SortOrder",
|
||||
"RequiredPermissions", "MetaPermissions", "MetaRoles", "AuthListJson",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt", "CreatedBy", "UpdatedBy", "DeletedBy", "Portal")
|
||||
VALUES (
|
||||
v_report_menu_id, v_parent_menu_id, 'BusinessReport', '/finance/report', '/finance/report/index', '经营报表', 'lucide:file-bar-chart-2',
|
||||
FALSE, NULL, TRUE, 530,
|
||||
'tenant:statistics:report:view', 'tenant:statistics:report:view,tenant:statistics:report:export', '', NULL,
|
||||
NOW(), NULL, NULL, NULL, NULL, NULL, 1)
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
ELSE
|
||||
UPDATE public.menu_definitions
|
||||
SET "ParentId" = v_parent_menu_id,
|
||||
"Name" = 'BusinessReport',
|
||||
"Path" = '/finance/report',
|
||||
"Component" = '/finance/report/index',
|
||||
"Title" = '经营报表',
|
||||
"Icon" = 'lucide:file-bar-chart-2',
|
||||
"IsIframe" = FALSE,
|
||||
"Link" = NULL,
|
||||
"KeepAlive" = TRUE,
|
||||
"SortOrder" = 530,
|
||||
"RequiredPermissions" = 'tenant:statistics:report:view',
|
||||
"MetaPermissions" = 'tenant:statistics:report:view,tenant:statistics:report:export',
|
||||
"MetaRoles" = '',
|
||||
"DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW(),
|
||||
"Portal" = 1
|
||||
WHERE "Id" = v_report_menu_id;
|
||||
END IF;
|
||||
|
||||
-- 6. 为 tenant-admin 角色授予权限。
|
||||
INSERT INTO public.role_permissions (
|
||||
"Id", "RoleId", "PermissionId", "CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy", "TenantId", "Portal")
|
||||
SELECT
|
||||
ABS(HASHTEXTEXTENDED('tenant-admin:report:' || role."Id"::text || ':' || permission_id::text, 0)),
|
||||
role."Id",
|
||||
permission_id,
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
role."TenantId",
|
||||
1
|
||||
FROM public.roles role
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT UNNEST(ARRAY[v_view_permission_id, v_export_permission_id]) AS permission_id
|
||||
) item
|
||||
WHERE role."Code" = 'tenant-admin'
|
||||
AND role."DeletedAt" IS NULL
|
||||
AND item.permission_id IS NOT NULL
|
||||
ON CONFLICT ("RoleId", "PermissionId") DO UPDATE
|
||||
SET "DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW(),
|
||||
"Portal" = 1;
|
||||
|
||||
-- 7. 为 tenant-admin 角色模板授予权限。
|
||||
INSERT INTO public.role_template_permissions (
|
||||
"Id", "RoleTemplateId", "PermissionCode",
|
||||
"CreatedAt", "UpdatedAt", "DeletedAt",
|
||||
"CreatedBy", "UpdatedBy", "DeletedBy")
|
||||
SELECT
|
||||
ABS(HASHTEXTEXTENDED('template-report:' || template."Id"::text || ':' || item.permission_code, 0)),
|
||||
template."Id",
|
||||
item.permission_code,
|
||||
NOW(), NULL, NULL,
|
||||
NULL, NULL, NULL
|
||||
FROM public.role_templates template
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT UNNEST(ARRAY['tenant:statistics:report:view', 'tenant:statistics:report:export']) AS permission_code
|
||||
) item
|
||||
WHERE template."TemplateCode" = 'tenant-admin'
|
||||
AND template."DeletedAt" IS NULL
|
||||
ON CONFLICT ("RoleTemplateId", "PermissionCode") DO UPDATE
|
||||
SET "DeletedAt" = NULL,
|
||||
"DeletedBy" = NULL,
|
||||
"UpdatedAt" = NOW();
|
||||
END $$;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
DELETE FROM public.role_permissions
|
||||
WHERE "PermissionId" IN (
|
||||
SELECT "Id"
|
||||
FROM public.permissions
|
||||
WHERE "Code" IN ('tenant:statistics:report:view', 'tenant:statistics:report:export'));
|
||||
|
||||
DELETE FROM public.role_template_permissions
|
||||
WHERE "PermissionCode" IN ('tenant:statistics:report:view', 'tenant:statistics:report:export');
|
||||
|
||||
DELETE FROM public.menu_definitions
|
||||
WHERE "Portal" = 1 AND "Path" = '/finance/report';
|
||||
|
||||
DELETE FROM public.permissions
|
||||
WHERE "Code" IN ('tenant:statistics:report:view', 'tenant:statistics:report:export');
|
||||
END $$;
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ public static class SchedulerServiceCollectionExtensions
|
||||
services.AddScoped<SubscriptionExpiryCheckJob>();
|
||||
services.AddScoped<SubscriptionAutoRenewalJob>();
|
||||
services.AddScoped<BillingOverdueProcessJob>();
|
||||
services.AddScoped<FinanceBusinessReportRefreshJob>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Domain.Finance.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Module.Scheduler.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 经营报表快照刷新任务:消费排队中的报表快照并触发生成。
|
||||
/// </summary>
|
||||
public sealed class FinanceBusinessReportRefreshJob(
|
||||
IFinanceBusinessReportRepository financeBusinessReportRepository,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
ILogger<FinanceBusinessReportRefreshJob> logger)
|
||||
{
|
||||
private const int BatchSize = 80;
|
||||
|
||||
/// <summary>
|
||||
/// 执行报表快照刷新。
|
||||
/// </summary>
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
// 1. 拉取待处理快照任务。
|
||||
var pendingList = await financeBusinessReportRepository.GetPendingSnapshotsAsync(BatchSize, CancellationToken.None);
|
||||
if (pendingList.Count == 0)
|
||||
{
|
||||
logger.LogDebug("定时任务:经营报表快照刷新无待处理任务");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 逐租户上下文执行生成。
|
||||
var successCount = 0;
|
||||
var failedCount = 0;
|
||||
foreach (var pending in pendingList)
|
||||
{
|
||||
var previousContext = tenantContextAccessor.Current;
|
||||
try
|
||||
{
|
||||
tenantContextAccessor.Current = new TenantContext(
|
||||
pending.TenantId,
|
||||
$"tenant-{pending.TenantId}",
|
||||
"scheduler");
|
||||
|
||||
await financeBusinessReportRepository.GenerateSnapshotAsync(
|
||||
pending.SnapshotId,
|
||||
CancellationToken.None);
|
||||
successCount += 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failedCount += 1;
|
||||
logger.LogError(
|
||||
ex,
|
||||
"定时任务:经营报表快照刷新失败 SnapshotId={SnapshotId}, TenantId={TenantId}",
|
||||
pending.SnapshotId,
|
||||
pending.TenantId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContextAccessor.Current = previousContext;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 记录执行结果。
|
||||
logger.LogInformation(
|
||||
"定时任务:经营报表快照刷新完成,处理 {TotalCount} 条,成功 {SuccessCount} 条,失败 {FailedCount} 条",
|
||||
pendingList.Count,
|
||||
successCount,
|
||||
failedCount);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,13 @@ public sealed class RecurringJobRegistrar(
|
||||
job => job.ExecuteAsync(),
|
||||
billingOptions.OverdueBillingProcessCron);
|
||||
|
||||
// 4. (空行后) 门店管理自动化任务
|
||||
// 4. 经营报表快照刷新任务
|
||||
RecurringJob.AddOrUpdate<FinanceBusinessReportRefreshJob>(
|
||||
"finance.business-report-refresh",
|
||||
job => job.ExecuteAsync(),
|
||||
"*/10 * * * *");
|
||||
|
||||
// 5. (空行后) 门店管理自动化任务
|
||||
RecurringJob.AddOrUpdate<BusinessStatusAutoSwitchJob>(
|
||||
"stores.business-status-auto-switch",
|
||||
job => job.ExecuteAsync(),
|
||||
|
||||
Reference in New Issue
Block a user