From 5dfaac01fd8fbb53fa5bad715e83181c2a2c8c47 Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Wed, 4 Mar 2026 16:57:06 +0800
Subject: [PATCH 1/2] feat(finance): implement invoice and business report
backend modules
---
.../Finance/FinanceBusinessReportContracts.cs | 285 +
.../Finance/FinanceInvoiceContracts.cs | 533 +
.../Controllers/FinanceInvoiceController.cs | 308 +
.../Controllers/FinanceReportController.cs | 250 +
.../ApplyFinanceInvoiceRecordCommand.cs | 60 +
.../IssueFinanceInvoiceRecordCommand.cs | 25 +
.../SaveFinanceInvoiceSettingCommand.cs | 60 +
.../VoidFinanceInvoiceRecordCommand.cs | 20 +
.../Dto/FinanceInvoiceIssueResultDto.cs | 47 +
.../Dto/FinanceInvoiceRecordDetailDto.cs | 112 +
.../Invoice/Dto/FinanceInvoiceRecordDto.cs | 62 +
.../Dto/FinanceInvoiceRecordListResultDto.cs | 32 +
.../Invoice/Dto/FinanceInvoiceSettingDto.cs | 57 +
.../Invoice/Dto/FinanceInvoiceStatsDto.cs | 27 +
.../Invoice/FinanceInvoiceDtoFactory.cs | 199 +
.../Finance/Invoice/FinanceInvoiceMapping.cs | 252 +
...ApplyFinanceInvoiceRecordCommandHandler.cs | 107 +
...tFinanceInvoiceRecordDetailQueryHandler.cs | 30 +
...GetFinanceInvoiceRecordListQueryHandler.cs | 50 +
...FinanceInvoiceSettingDetailQueryHandler.cs | 29 +
...IssueFinanceInvoiceRecordCommandHandler.cs | 65 +
...SaveFinanceInvoiceSettingCommandHandler.cs | 72 +
.../VoidFinanceInvoiceRecordCommandHandler.cs | 46 +
.../GetFinanceInvoiceRecordDetailQuery.cs | 15 +
.../GetFinanceInvoiceRecordListQuery.cs | 46 +
.../GetFinanceInvoiceSettingDetailQuery.cs | 11 +
.../Reports/Dto/FinanceBusinessReportDtos.cs | 218 +
...tFinanceBusinessReportBatchQueryHandler.cs | 112 +
...tFinanceBusinessReportExcelQueryHandler.cs | 59 +
...ortFinanceBusinessReportPdfQueryHandler.cs | 59 +
.../Handlers/FinanceBusinessReportMapping.cs | 178 +
...FinanceBusinessReportDetailQueryHandler.cs | 39 +
...chFinanceBusinessReportListQueryHandler.cs | 57 +
.../ExportFinanceBusinessReportBatchQuery.cs | 31 +
.../ExportFinanceBusinessReportExcelQuery.cs | 20 +
.../ExportFinanceBusinessReportPdfQuery.cs | 20 +
.../GetFinanceBusinessReportDetailQuery.cs | 20 +
.../SearchFinanceBusinessReportListQuery.cs | 31 +
...inanceBusinessReportBatchQueryValidator.cs | 20 +
...inanceBusinessReportExcelQueryValidator.cs | 19 +
...tFinanceBusinessReportPdfQueryValidator.cs | 19 +
...nanceBusinessReportDetailQueryValidator.cs | 19 +
...FinanceBusinessReportListQueryValidator.cs | 20 +
.../Entities/FinanceBusinessReportSnapshot.cs | 111 +
.../Entities/FinanceCostDailyOverride.cs | 36 +
.../Finance/Entities/FinanceCostProfile.cs | 56 +
.../Enums/FinanceBusinessReportPeriodType.cs | 23 +
.../Enums/FinanceBusinessReportStatus.cs | 28 +
.../Finance/Enums/FinanceCostCalcMode.cs | 18 +
.../Models/FinanceBusinessReportSnapshots.cs | 245 +
.../IFinanceBusinessReportRepository.cs | 77 +
.../IFinanceBusinessReportExportService.cs | 38 +
.../Tenants/Entities/TenantInvoiceRecord.cs | 100 +
.../Tenants/Entities/TenantInvoiceSetting.cs | 59 +
.../Tenants/Enums/TenantInvoiceStatus.cs | 22 +
.../Tenants/Enums/TenantInvoiceType.cs | 17 +
.../Repositories/ITenantInvoiceRepository.cs | 104 +
.../AppServiceCollectionExtensions.cs | 3 +
.../App/Persistence/TakeoutAppDbContext.cs | 134 +
.../EfFinanceBusinessReportRepository.cs | 761 ++
.../Repositories/EfTenantInvoiceRepository.cs | 215 +
.../FinanceBusinessReportExportService.cs | 303 +
...071623_AddFinanceInvoiceModule.Designer.cs | 10972 ++++++++++++++++
.../20260304071623_AddFinanceInvoiceModule.cs | 214 +
...05090000_AddFinanceBusinessReportModule.cs | 164 +
...000_SeedFinanceReportMenuAndPermissions.cs | 249 +
.../SchedulerServiceCollectionExtensions.cs | 1 +
.../Jobs/FinanceBusinessReportRefreshJob.cs | 70 +
.../Services/RecurringJobRegistrar.cs | 8 +-
69 files changed, 17768 insertions(+), 1 deletion(-)
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceBusinessReportContracts.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceInvoiceContracts.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceInvoiceController.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceReportController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/ApplyFinanceInvoiceRecordCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/IssueFinanceInvoiceRecordCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/SaveFinanceInvoiceSettingCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/VoidFinanceInvoiceRecordCommand.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceIssueResultDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceRecordDetailDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceRecordDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceRecordListResultDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceSettingDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceStatsDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/FinanceInvoiceDtoFactory.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/FinanceInvoiceMapping.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/ApplyFinanceInvoiceRecordCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/GetFinanceInvoiceRecordDetailQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/GetFinanceInvoiceRecordListQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/GetFinanceInvoiceSettingDetailQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/IssueFinanceInvoiceRecordCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/SaveFinanceInvoiceSettingCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/VoidFinanceInvoiceRecordCommandHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Queries/GetFinanceInvoiceRecordDetailQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Queries/GetFinanceInvoiceRecordListQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Queries/GetFinanceInvoiceSettingDetailQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Dto/FinanceBusinessReportDtos.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportBatchQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportExcelQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportPdfQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/FinanceBusinessReportMapping.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/GetFinanceBusinessReportDetailQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/SearchFinanceBusinessReportListQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportBatchQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportExcelQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportPdfQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/GetFinanceBusinessReportDetailQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/SearchFinanceBusinessReportListQuery.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportBatchQueryValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportExcelQueryValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportPdfQueryValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/GetFinanceBusinessReportDetailQueryValidator.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/SearchFinanceBusinessReportListQueryValidator.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceBusinessReportSnapshot.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostDailyOverride.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostProfile.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportPeriodType.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportStatus.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostCalcMode.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceBusinessReportSnapshots.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceBusinessReportRepository.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Finance/Services/IFinanceBusinessReportExportService.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantInvoiceRecord.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantInvoiceSetting.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantInvoiceStatus.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantInvoiceType.cs
create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantInvoiceRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceBusinessReportRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantInvoiceRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/FinanceBusinessReportExportService.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304071623_AddFinanceInvoiceModule.Designer.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304071623_AddFinanceInvoiceModule.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305090000_AddFinanceBusinessReportModule.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20260305093000_SeedFinanceReportMenuAndPermissions.cs
create mode 100644 src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/FinanceBusinessReportRefreshJob.cs
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceBusinessReportContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceBusinessReportContracts.cs
new file mode 100644
index 0000000..b0884b6
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceBusinessReportContracts.cs
@@ -0,0 +1,285 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Finance;
+
+///
+/// 经营报表列表请求。
+///
+public sealed class FinanceBusinessReportListRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 周期类型(daily/weekly/monthly)。
+ ///
+ public string? PeriodType { get; set; } = "daily";
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 20;
+}
+
+///
+/// 经营报表详情请求。
+///
+public sealed class FinanceBusinessReportDetailRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 报表 ID。
+ ///
+ public string ReportId { get; set; } = string.Empty;
+}
+
+///
+/// 经营报表批量导出请求。
+///
+public sealed class FinanceBusinessReportBatchExportRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 周期类型(daily/weekly/monthly)。
+ ///
+ public string? PeriodType { get; set; } = "daily";
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 20;
+}
+
+///
+/// 经营报表列表行响应。
+///
+public sealed class FinanceBusinessReportListItemResponse
+{
+ ///
+ /// 报表 ID。
+ ///
+ public string ReportId { get; set; } = string.Empty;
+
+ ///
+ /// 日期文案。
+ ///
+ public string DateText { get; set; } = string.Empty;
+
+ ///
+ /// 营业额。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 订单数。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageOrderValue { get; set; }
+
+ ///
+ /// 退款率(百分数)。
+ ///
+ public decimal RefundRatePercent { get; set; }
+
+ ///
+ /// 成本总额。
+ ///
+ public decimal CostTotalAmount { get; set; }
+
+ ///
+ /// 净利润。
+ ///
+ public decimal NetProfitAmount { get; set; }
+
+ ///
+ /// 利润率(百分数)。
+ ///
+ public decimal ProfitRatePercent { get; set; }
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// 是否可下载。
+ ///
+ public bool CanDownload { get; set; }
+}
+
+///
+/// 经营报表列表响应。
+///
+public sealed class FinanceBusinessReportListResultResponse
+{
+ ///
+ /// 列表。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 总数。
+ ///
+ public int Total { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+}
+
+///
+/// KPI 响应项。
+///
+public sealed class FinanceBusinessReportKpiResponse
+{
+ ///
+ /// 指标键。
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 指标名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 指标值文案。
+ ///
+ public string ValueText { get; set; } = string.Empty;
+
+ ///
+ /// 同比变化率(百分数)。
+ ///
+ public decimal YoyChangeRate { get; set; }
+
+ ///
+ /// 环比变化率(百分数)。
+ ///
+ public decimal MomChangeRate { get; set; }
+}
+
+///
+/// 明细行响应项。
+///
+public sealed class FinanceBusinessReportBreakdownItemResponse
+{
+ ///
+ /// 明细键。
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 明细名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 占比(百分数)。
+ ///
+ public decimal RatioPercent { get; set; }
+}
+
+///
+/// 经营报表详情响应。
+///
+public sealed class FinanceBusinessReportDetailResponse
+{
+ ///
+ /// 报表 ID。
+ ///
+ public string ReportId { get; set; } = string.Empty;
+
+ ///
+ /// 标题。
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// 周期类型编码。
+ ///
+ public string PeriodType { get; set; } = string.Empty;
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// KPI 列表。
+ ///
+ public List Kpis { get; set; } = [];
+
+ ///
+ /// 收入明细(按渠道)。
+ ///
+ public List IncomeBreakdowns { get; set; } = [];
+
+ ///
+ /// 成本明细(按类别)。
+ ///
+ public List CostBreakdowns { get; set; } = [];
+}
+
+///
+/// 经营报表导出响应。
+///
+public sealed class FinanceBusinessReportExportResponse
+{
+ ///
+ /// 文件名。
+ ///
+ public string FileName { get; set; } = string.Empty;
+
+ ///
+ /// Base64 文件内容。
+ ///
+ public string FileContentBase64 { get; set; } = string.Empty;
+
+ ///
+ /// 导出总记录数。
+ ///
+ public int TotalCount { get; set; }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceInvoiceContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceInvoiceContracts.cs
new file mode 100644
index 0000000..938b22d
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Finance/FinanceInvoiceContracts.cs
@@ -0,0 +1,533 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Finance;
+
+///
+/// 保存发票设置请求。
+///
+public sealed class FinanceInvoiceSettingSaveRequest
+{
+ ///
+ /// 企业名称。
+ ///
+ public string CompanyName { get; set; } = string.Empty;
+
+ ///
+ /// 纳税人识别号。
+ ///
+ public string TaxpayerNumber { get; set; } = string.Empty;
+
+ ///
+ /// 注册地址。
+ ///
+ public string? RegisteredAddress { get; set; }
+
+ ///
+ /// 注册电话。
+ ///
+ public string? RegisteredPhone { get; set; }
+
+ ///
+ /// 开户银行。
+ ///
+ public string? BankName { get; set; }
+
+ ///
+ /// 银行账号。
+ ///
+ public string? BankAccount { get; set; }
+
+ ///
+ /// 是否启用电子普通发票。
+ ///
+ public bool EnableElectronicNormalInvoice { get; set; } = true;
+
+ ///
+ /// 是否启用电子专用发票。
+ ///
+ public bool EnableElectronicSpecialInvoice { get; set; }
+
+ ///
+ /// 是否启用自动开票。
+ ///
+ public bool EnableAutoIssue { get; set; }
+
+ ///
+ /// 自动开票单张最大金额。
+ ///
+ public decimal AutoIssueMaxAmount { get; set; } = 10_000m;
+}
+
+///
+/// 发票记录列表请求。
+///
+public sealed class FinanceInvoiceRecordListRequest
+{
+ ///
+ /// 开始日期(yyyy-MM-dd)。
+ ///
+ public string? StartDate { get; set; }
+
+ ///
+ /// 结束日期(yyyy-MM-dd)。
+ ///
+ public string? EndDate { get; set; }
+
+ ///
+ /// 状态(pending/issued/voided)。
+ ///
+ public string? Status { get; set; }
+
+ ///
+ /// 类型(normal/special)。
+ ///
+ public string? InvoiceType { get; set; }
+
+ ///
+ /// 关键词(发票号/公司名/申请人)。
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; } = 10;
+}
+
+///
+/// 发票记录详情请求。
+///
+public sealed class FinanceInvoiceRecordDetailRequest
+{
+ ///
+ /// 发票记录 ID。
+ ///
+ public string RecordId { get; set; } = string.Empty;
+}
+
+///
+/// 发票开票请求。
+///
+public sealed class FinanceInvoiceRecordIssueRequest
+{
+ ///
+ /// 发票记录 ID。
+ ///
+ public string RecordId { get; set; } = string.Empty;
+
+ ///
+ /// 接收邮箱(可选)。
+ ///
+ public string? ContactEmail { get; set; }
+
+ ///
+ /// 开票备注。
+ ///
+ public string? IssueRemark { get; set; }
+}
+
+///
+/// 发票作废请求。
+///
+public sealed class FinanceInvoiceRecordVoidRequest
+{
+ ///
+ /// 发票记录 ID。
+ ///
+ public string RecordId { get; set; } = string.Empty;
+
+ ///
+ /// 作废原因。
+ ///
+ public string VoidReason { get; set; } = string.Empty;
+}
+
+///
+/// 发票申请请求。
+///
+public sealed class FinanceInvoiceRecordApplyRequest
+{
+ ///
+ /// 申请人。
+ ///
+ public string ApplicantName { get; set; } = string.Empty;
+
+ ///
+ /// 开票抬头(公司名)。
+ ///
+ public string CompanyName { get; set; } = string.Empty;
+
+ ///
+ /// 纳税人识别号。
+ ///
+ public string? TaxpayerNumber { get; set; }
+
+ ///
+ /// 发票类型(normal/special)。
+ ///
+ public string InvoiceType { get; set; } = "normal";
+
+ ///
+ /// 开票金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 关联订单号。
+ ///
+ public string OrderNo { get; set; } = string.Empty;
+
+ ///
+ /// 接收邮箱。
+ ///
+ public string? ContactEmail { get; set; }
+
+ ///
+ /// 联系电话。
+ ///
+ public string? ContactPhone { get; set; }
+
+ ///
+ /// 申请备注。
+ ///
+ public string? ApplyRemark { get; set; }
+
+ ///
+ /// 申请时间(可空)。
+ ///
+ public DateTime? AppliedAt { get; set; }
+}
+
+///
+/// 发票设置响应。
+///
+public sealed class FinanceInvoiceSettingResponse
+{
+ ///
+ /// 企业名称。
+ ///
+ public string CompanyName { get; set; } = string.Empty;
+
+ ///
+ /// 纳税人识别号。
+ ///
+ public string TaxpayerNumber { get; set; } = string.Empty;
+
+ ///
+ /// 注册地址。
+ ///
+ public string? RegisteredAddress { get; set; }
+
+ ///
+ /// 注册电话。
+ ///
+ public string? RegisteredPhone { get; set; }
+
+ ///
+ /// 开户银行。
+ ///
+ public string? BankName { get; set; }
+
+ ///
+ /// 银行账号。
+ ///
+ public string? BankAccount { get; set; }
+
+ ///
+ /// 是否启用电子普通发票。
+ ///
+ public bool EnableElectronicNormalInvoice { get; set; }
+
+ ///
+ /// 是否启用电子专用发票。
+ ///
+ public bool EnableElectronicSpecialInvoice { get; set; }
+
+ ///
+ /// 是否启用自动开票。
+ ///
+ public bool EnableAutoIssue { get; set; }
+
+ ///
+ /// 自动开票单张最大金额。
+ ///
+ public decimal AutoIssueMaxAmount { get; set; }
+}
+
+///
+/// 发票统计响应。
+///
+public sealed class FinanceInvoiceStatsResponse
+{
+ ///
+ /// 本月已开票金额。
+ ///
+ public decimal CurrentMonthIssuedAmount { get; set; }
+
+ ///
+ /// 本月已开票张数。
+ ///
+ public int CurrentMonthIssuedCount { get; set; }
+
+ ///
+ /// 待开票数量。
+ ///
+ public int PendingCount { get; set; }
+
+ ///
+ /// 已作废数量。
+ ///
+ public int VoidedCount { get; set; }
+}
+
+///
+/// 发票记录列表项响应。
+///
+public sealed class FinanceInvoiceRecordResponse
+{
+ ///
+ /// 记录 ID。
+ ///
+ public string RecordId { get; set; } = string.Empty;
+
+ ///
+ /// 发票号码。
+ ///
+ public string InvoiceNo { get; set; } = string.Empty;
+
+ ///
+ /// 申请人。
+ ///
+ public string ApplicantName { get; set; } = string.Empty;
+
+ ///
+ /// 开票抬头(公司名)。
+ ///
+ public string CompanyName { get; set; } = string.Empty;
+
+ ///
+ /// 发票类型编码。
+ ///
+ public string InvoiceType { get; set; } = string.Empty;
+
+ ///
+ /// 发票类型文案。
+ ///
+ public string InvoiceTypeText { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 关联订单号。
+ ///
+ public string OrderNo { get; set; } = string.Empty;
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// 申请时间(本地显示字符串)。
+ ///
+ public string AppliedAt { get; set; } = string.Empty;
+}
+
+///
+/// 发票记录详情响应。
+///
+public sealed class FinanceInvoiceRecordDetailResponse
+{
+ ///
+ /// 记录 ID。
+ ///
+ public string RecordId { get; set; } = string.Empty;
+
+ ///
+ /// 发票号码。
+ ///
+ public string InvoiceNo { get; set; } = string.Empty;
+
+ ///
+ /// 申请人。
+ ///
+ public string ApplicantName { get; set; } = string.Empty;
+
+ ///
+ /// 开票抬头(公司名)。
+ ///
+ public string CompanyName { get; set; } = string.Empty;
+
+ ///
+ /// 纳税人识别号。
+ ///
+ public string? TaxpayerNumber { get; set; }
+
+ ///
+ /// 发票类型编码。
+ ///
+ public string InvoiceType { get; set; } = string.Empty;
+
+ ///
+ /// 发票类型文案。
+ ///
+ public string InvoiceTypeText { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 关联订单号。
+ ///
+ public string OrderNo { get; set; } = string.Empty;
+
+ ///
+ /// 接收邮箱。
+ ///
+ public string? ContactEmail { get; set; }
+
+ ///
+ /// 联系电话。
+ ///
+ public string? ContactPhone { get; set; }
+
+ ///
+ /// 申请备注。
+ ///
+ public string? ApplyRemark { get; set; }
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// 申请时间(本地显示字符串)。
+ ///
+ public string AppliedAt { get; set; } = string.Empty;
+
+ ///
+ /// 开票时间(本地显示字符串)。
+ ///
+ public string? IssuedAt { get; set; }
+
+ ///
+ /// 开票人 ID。
+ ///
+ public string? IssuedByUserId { get; set; }
+
+ ///
+ /// 开票备注。
+ ///
+ public string? IssueRemark { get; set; }
+
+ ///
+ /// 作废时间(本地显示字符串)。
+ ///
+ public string? VoidedAt { get; set; }
+
+ ///
+ /// 作废人 ID。
+ ///
+ public string? VoidedByUserId { get; set; }
+
+ ///
+ /// 作废原因。
+ ///
+ public string? VoidReason { get; set; }
+}
+
+///
+/// 发票开票结果响应。
+///
+public sealed class FinanceInvoiceIssueResultResponse
+{
+ ///
+ /// 记录 ID。
+ ///
+ public string RecordId { get; set; } = string.Empty;
+
+ ///
+ /// 发票号码。
+ ///
+ public string InvoiceNo { get; set; } = string.Empty;
+
+ ///
+ /// 开票抬头。
+ ///
+ public string CompanyName { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 接收邮箱。
+ ///
+ public string? ContactEmail { get; set; }
+
+ ///
+ /// 开票时间(本地显示字符串)。
+ ///
+ public string IssuedAt { get; set; } = string.Empty;
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+}
+
+///
+/// 发票记录分页响应。
+///
+public sealed class FinanceInvoiceRecordListResultResponse
+{
+ ///
+ /// 列表项。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+
+ ///
+ /// 总条数。
+ ///
+ public int TotalCount { get; set; }
+
+ ///
+ /// 统计。
+ ///
+ public FinanceInvoiceStatsResponse Stats { get; set; } = new();
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceInvoiceController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceInvoiceController.cs
new file mode 100644
index 0000000..949b0b6
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceInvoiceController.cs
@@ -0,0 +1,308 @@
+using System.Globalization;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Module.Authorization.Attributes;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+using TakeoutSaaS.TenantApi.Contracts.Finance;
+
+namespace TakeoutSaaS.TenantApi.Controllers;
+
+///
+/// 财务中心发票管理。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/tenant/v{version:apiVersion}/finance/invoice")]
+public sealed class FinanceInvoiceController(IMediator mediator) : BaseApiController
+{
+ private const string ViewPermission = "tenant:finance:invoice:view";
+ private const string IssuePermission = "tenant:finance:invoice:issue";
+ private const string VoidPermission = "tenant:finance:invoice:void";
+ private const string SettingsPermission = "tenant:finance:invoice:settings";
+
+ ///
+ /// 查询发票设置详情。
+ ///
+ [HttpGet("settings/detail")]
+ [PermissionAuthorize(ViewPermission, SettingsPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> SettingsDetail(CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new GetFinanceInvoiceSettingDetailQuery(), cancellationToken);
+ return ApiResponse.Ok(MapSetting(result));
+ }
+
+ ///
+ /// 保存发票设置。
+ ///
+ [HttpPost("settings/save")]
+ [PermissionAuthorize(SettingsPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> SettingsSave(
+ [FromBody] FinanceInvoiceSettingSaveRequest request,
+ CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new SaveFinanceInvoiceSettingCommand
+ {
+ CompanyName = request.CompanyName,
+ TaxpayerNumber = request.TaxpayerNumber,
+ RegisteredAddress = request.RegisteredAddress,
+ RegisteredPhone = request.RegisteredPhone,
+ BankName = request.BankName,
+ BankAccount = request.BankAccount,
+ EnableElectronicNormalInvoice = request.EnableElectronicNormalInvoice,
+ EnableElectronicSpecialInvoice = request.EnableElectronicSpecialInvoice,
+ EnableAutoIssue = request.EnableAutoIssue,
+ AutoIssueMaxAmount = request.AutoIssueMaxAmount
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapSetting(result));
+ }
+
+ ///
+ /// 查询发票记录分页。
+ ///
+ [HttpGet("record/list")]
+ [PermissionAuthorize(ViewPermission, IssuePermission, VoidPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> RecordList(
+ [FromQuery] FinanceInvoiceRecordListRequest request,
+ CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new GetFinanceInvoiceRecordListQuery
+ {
+ StartDateUtc = ParseDateOrNull(request.StartDate, nameof(request.StartDate)),
+ EndDateUtc = ParseDateOrNull(request.EndDate, nameof(request.EndDate)),
+ Status = ParseStatusOrNull(request.Status),
+ InvoiceType = ParseInvoiceTypeOrNull(request.InvoiceType),
+ Keyword = request.Keyword,
+ Page = request.Page,
+ PageSize = request.PageSize
+ }, cancellationToken);
+
+ return ApiResponse.Ok(new FinanceInvoiceRecordListResultResponse
+ {
+ Items = result.Items.Select(MapRecord).ToList(),
+ Page = result.Page,
+ PageSize = result.PageSize,
+ TotalCount = result.TotalCount,
+ Stats = new FinanceInvoiceStatsResponse
+ {
+ CurrentMonthIssuedAmount = result.Stats.CurrentMonthIssuedAmount,
+ CurrentMonthIssuedCount = result.Stats.CurrentMonthIssuedCount,
+ PendingCount = result.Stats.PendingCount,
+ VoidedCount = result.Stats.VoidedCount
+ }
+ });
+ }
+
+ ///
+ /// 查询发票记录详情。
+ ///
+ [HttpGet("record/detail")]
+ [PermissionAuthorize(ViewPermission, IssuePermission, VoidPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> RecordDetail(
+ [FromQuery] FinanceInvoiceRecordDetailRequest request,
+ CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new GetFinanceInvoiceRecordDetailQuery
+ {
+ RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId))
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapRecordDetail(result));
+ }
+
+ ///
+ /// 发票开票。
+ ///
+ [HttpPost("record/issue")]
+ [PermissionAuthorize(IssuePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> RecordIssue(
+ [FromBody] FinanceInvoiceRecordIssueRequest request,
+ CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new IssueFinanceInvoiceRecordCommand
+ {
+ RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
+ ContactEmail = request.ContactEmail,
+ IssueRemark = request.IssueRemark
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapIssueResult(result));
+ }
+
+ ///
+ /// 作废发票。
+ ///
+ [HttpPost("record/void")]
+ [PermissionAuthorize(VoidPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> RecordVoid(
+ [FromBody] FinanceInvoiceRecordVoidRequest request,
+ CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new VoidFinanceInvoiceRecordCommand
+ {
+ RecordId = StoreApiHelpers.ParseRequiredSnowflake(request.RecordId, nameof(request.RecordId)),
+ VoidReason = request.VoidReason
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapRecordDetail(result));
+ }
+
+ ///
+ /// 申请发票。
+ ///
+ [HttpPost("record/apply")]
+ [PermissionAuthorize(ViewPermission, IssuePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> RecordApply(
+ [FromBody] FinanceInvoiceRecordApplyRequest request,
+ CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new ApplyFinanceInvoiceRecordCommand
+ {
+ ApplicantName = request.ApplicantName,
+ CompanyName = request.CompanyName,
+ TaxpayerNumber = request.TaxpayerNumber,
+ InvoiceType = request.InvoiceType,
+ Amount = request.Amount,
+ OrderNo = request.OrderNo,
+ ContactEmail = request.ContactEmail,
+ ContactPhone = request.ContactPhone,
+ ApplyRemark = request.ApplyRemark,
+ AppliedAt = request.AppliedAt
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapRecordDetail(result));
+ }
+
+ private static DateTime? ParseDateOrNull(string? value, string fieldName)
+ {
+ return string.IsNullOrWhiteSpace(value)
+ ? null
+ : StoreApiHelpers.ParseDateOnly(value, fieldName);
+ }
+
+ private static TenantInvoiceStatus? ParseStatusOrNull(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ return null;
+ }
+
+ return normalized switch
+ {
+ "pending" => TenantInvoiceStatus.Pending,
+ "issued" => TenantInvoiceStatus.Issued,
+ "voided" => TenantInvoiceStatus.Voided,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
+ };
+ }
+
+ private static TenantInvoiceType? ParseInvoiceTypeOrNull(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ return null;
+ }
+
+ return normalized switch
+ {
+ "normal" => TenantInvoiceType.Normal,
+ "special" => TenantInvoiceType.Special,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "invoiceType 参数不合法")
+ };
+ }
+
+ private static FinanceInvoiceSettingResponse MapSetting(FinanceInvoiceSettingDto source)
+ {
+ return new FinanceInvoiceSettingResponse
+ {
+ CompanyName = source.CompanyName,
+ TaxpayerNumber = source.TaxpayerNumber,
+ RegisteredAddress = source.RegisteredAddress,
+ RegisteredPhone = source.RegisteredPhone,
+ BankName = source.BankName,
+ BankAccount = source.BankAccount,
+ EnableElectronicNormalInvoice = source.EnableElectronicNormalInvoice,
+ EnableElectronicSpecialInvoice = source.EnableElectronicSpecialInvoice,
+ EnableAutoIssue = source.EnableAutoIssue,
+ AutoIssueMaxAmount = source.AutoIssueMaxAmount
+ };
+ }
+
+ private static FinanceInvoiceRecordResponse MapRecord(FinanceInvoiceRecordDto source)
+ {
+ return new FinanceInvoiceRecordResponse
+ {
+ RecordId = source.RecordId.ToString(),
+ InvoiceNo = source.InvoiceNo,
+ ApplicantName = source.ApplicantName,
+ CompanyName = source.CompanyName,
+ InvoiceType = source.InvoiceType,
+ InvoiceTypeText = source.InvoiceTypeText,
+ Amount = source.Amount,
+ OrderNo = source.OrderNo,
+ Status = source.Status,
+ StatusText = source.StatusText,
+ AppliedAt = source.AppliedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
+ };
+ }
+
+ private static FinanceInvoiceRecordDetailResponse MapRecordDetail(FinanceInvoiceRecordDetailDto source)
+ {
+ return new FinanceInvoiceRecordDetailResponse
+ {
+ RecordId = source.RecordId.ToString(),
+ InvoiceNo = source.InvoiceNo,
+ ApplicantName = source.ApplicantName,
+ CompanyName = source.CompanyName,
+ TaxpayerNumber = source.TaxpayerNumber,
+ InvoiceType = source.InvoiceType,
+ InvoiceTypeText = source.InvoiceTypeText,
+ Amount = source.Amount,
+ OrderNo = source.OrderNo,
+ ContactEmail = source.ContactEmail,
+ ContactPhone = source.ContactPhone,
+ ApplyRemark = source.ApplyRemark,
+ Status = source.Status,
+ StatusText = source.StatusText,
+ AppliedAt = source.AppliedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
+ IssuedAt = source.IssuedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
+ IssuedByUserId = source.IssuedByUserId?.ToString(),
+ IssueRemark = source.IssueRemark,
+ VoidedAt = source.VoidedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
+ VoidedByUserId = source.VoidedByUserId?.ToString(),
+ VoidReason = source.VoidReason
+ };
+ }
+
+ private static FinanceInvoiceIssueResultResponse MapIssueResult(FinanceInvoiceIssueResultDto source)
+ {
+ return new FinanceInvoiceIssueResultResponse
+ {
+ RecordId = source.RecordId.ToString(),
+ InvoiceNo = source.InvoiceNo,
+ CompanyName = source.CompanyName,
+ Amount = source.Amount,
+ ContactEmail = source.ContactEmail,
+ IssuedAt = source.IssuedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
+ Status = source.Status,
+ StatusText = source.StatusText
+ };
+ }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceReportController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceReportController.cs
new file mode 100644
index 0000000..a2cfc81
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/FinanceReportController.cs
@@ -0,0 +1,250 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+using TakeoutSaaS.Application.App.Stores.Services;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Module.Authorization.Attributes;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+using TakeoutSaaS.TenantApi.Contracts.Finance;
+
+namespace TakeoutSaaS.TenantApi.Controllers;
+
+///
+/// 财务中心经营报表。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/tenant/v{version:apiVersion}/finance/report")]
+public sealed class FinanceReportController(
+ IMediator mediator,
+ TakeoutAppDbContext dbContext,
+ StoreContextService storeContextService) : BaseApiController
+{
+ private const string ViewPermission = "tenant:statistics:report:view";
+ private const string ExportPermission = "tenant:statistics:report:export";
+
+ ///
+ /// 查询经营报表列表。
+ ///
+ [HttpGet("list")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> List(
+ [FromQuery] FinanceBusinessReportListRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 校验门店访问权限并解析查询参数。
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+ var periodType = ParsePeriodType(request.PeriodType);
+
+ // 2. 发起查询并返回结果。
+ var result = await mediator.Send(new SearchFinanceBusinessReportListQuery
+ {
+ StoreId = storeId,
+ PeriodType = periodType,
+ Page = Math.Max(1, request.Page),
+ PageSize = Math.Clamp(request.PageSize, 1, 200)
+ }, cancellationToken);
+
+ return ApiResponse.Ok(new FinanceBusinessReportListResultResponse
+ {
+ Items = result.Items.Select(MapListItem).ToList(),
+ Total = result.Total,
+ Page = result.Page,
+ PageSize = result.PageSize
+ });
+ }
+
+ ///
+ /// 查询经营报表详情。
+ ///
+ [HttpGet("detail")]
+ [PermissionAuthorize(ViewPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Detail(
+ [FromQuery] FinanceBusinessReportDetailRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 校验门店访问权限并解析参数。
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+ var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
+
+ // 2. 发起详情查询。
+ var detail = await mediator.Send(new GetFinanceBusinessReportDetailQuery
+ {
+ StoreId = storeId,
+ ReportId = reportId
+ }, cancellationToken);
+
+ if (detail is null)
+ {
+ return ApiResponse.Error(ErrorCodes.NotFound, "经营报表不存在");
+ }
+
+ return ApiResponse.Ok(MapDetail(detail));
+ }
+
+ ///
+ /// 导出单条报表 PDF。
+ ///
+ [HttpGet("export/pdf")]
+ [PermissionAuthorize(ExportPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> ExportPdf(
+ [FromQuery] FinanceBusinessReportDetailRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 校验门店访问权限并解析参数。
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+ var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
+
+ // 2. 执行导出。
+ var export = await mediator.Send(new ExportFinanceBusinessReportPdfQuery
+ {
+ StoreId = storeId,
+ ReportId = reportId
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapExport(export));
+ }
+
+ ///
+ /// 导出单条报表 Excel。
+ ///
+ [HttpGet("export/excel")]
+ [PermissionAuthorize(ExportPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> ExportExcel(
+ [FromQuery] FinanceBusinessReportDetailRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 校验门店访问权限并解析参数。
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+ var reportId = StoreApiHelpers.ParseRequiredSnowflake(request.ReportId, nameof(request.ReportId));
+
+ // 2. 执行导出。
+ var export = await mediator.Send(new ExportFinanceBusinessReportExcelQuery
+ {
+ StoreId = storeId,
+ ReportId = reportId
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapExport(export));
+ }
+
+ ///
+ /// 批量导出报表 ZIP(PDF + Excel)。
+ ///
+ [HttpGet("export/batch")]
+ [PermissionAuthorize(ExportPermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> ExportBatch(
+ [FromQuery] FinanceBusinessReportBatchExportRequest request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 校验门店访问权限并解析参数。
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+ var periodType = ParsePeriodType(request.PeriodType);
+
+ // 2. 执行批量导出。
+ var export = await mediator.Send(new ExportFinanceBusinessReportBatchQuery
+ {
+ StoreId = storeId,
+ PeriodType = periodType,
+ Page = Math.Max(1, request.Page),
+ PageSize = Math.Clamp(request.PageSize, 1, 200)
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapExport(export));
+ }
+
+ private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
+ {
+ var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
+ await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
+ }
+
+ private static FinanceBusinessReportPeriodType ParsePeriodType(string? value)
+ {
+ return (value ?? string.Empty).Trim().ToLowerInvariant() switch
+ {
+ "" or "daily" => FinanceBusinessReportPeriodType.Daily,
+ "weekly" => FinanceBusinessReportPeriodType.Weekly,
+ "monthly" => FinanceBusinessReportPeriodType.Monthly,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "periodType 参数不合法")
+ };
+ }
+
+ private static FinanceBusinessReportListItemResponse MapListItem(FinanceBusinessReportListItemDto source)
+ {
+ return new FinanceBusinessReportListItemResponse
+ {
+ ReportId = source.ReportId,
+ DateText = source.DateText,
+ RevenueAmount = source.RevenueAmount,
+ OrderCount = source.OrderCount,
+ AverageOrderValue = source.AverageOrderValue,
+ RefundRatePercent = source.RefundRatePercent,
+ CostTotalAmount = source.CostTotalAmount,
+ NetProfitAmount = source.NetProfitAmount,
+ ProfitRatePercent = source.ProfitRatePercent,
+ Status = source.Status,
+ StatusText = source.StatusText,
+ CanDownload = source.CanDownload
+ };
+ }
+
+ private static FinanceBusinessReportDetailResponse MapDetail(FinanceBusinessReportDetailDto source)
+ {
+ return new FinanceBusinessReportDetailResponse
+ {
+ ReportId = source.ReportId,
+ Title = source.Title,
+ PeriodType = source.PeriodType,
+ Status = source.Status,
+ StatusText = source.StatusText,
+ Kpis = source.Kpis.Select(item => new FinanceBusinessReportKpiResponse
+ {
+ Key = item.Key,
+ Label = item.Label,
+ ValueText = item.ValueText,
+ YoyChangeRate = item.YoyChangeRate,
+ MomChangeRate = item.MomChangeRate
+ }).ToList(),
+ IncomeBreakdowns = source.IncomeBreakdowns.Select(MapBreakdown).ToList(),
+ CostBreakdowns = source.CostBreakdowns.Select(MapBreakdown).ToList()
+ };
+ }
+
+ private static FinanceBusinessReportBreakdownItemResponse MapBreakdown(FinanceBusinessReportBreakdownItemDto source)
+ {
+ return new FinanceBusinessReportBreakdownItemResponse
+ {
+ Key = source.Key,
+ Label = source.Label,
+ Amount = source.Amount,
+ RatioPercent = source.RatioPercent
+ };
+ }
+
+ private static FinanceBusinessReportExportResponse MapExport(FinanceBusinessReportExportDto source)
+ {
+ return new FinanceBusinessReportExportResponse
+ {
+ FileName = source.FileName,
+ FileContentBase64 = source.FileContentBase64,
+ TotalCount = source.TotalCount
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/ApplyFinanceInvoiceRecordCommand.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/ApplyFinanceInvoiceRecordCommand.cs
new file mode 100644
index 0000000..55c77ca
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/ApplyFinanceInvoiceRecordCommand.cs
@@ -0,0 +1,60 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
+
+///
+/// 申请发票记录命令。
+///
+public sealed class ApplyFinanceInvoiceRecordCommand : IRequest
+{
+ ///
+ /// 申请人。
+ ///
+ public string ApplicantName { get; init; } = string.Empty;
+
+ ///
+ /// 开票抬头(公司名)。
+ ///
+ public string CompanyName { get; init; } = string.Empty;
+
+ ///
+ /// 纳税人识别号。
+ ///
+ public string? TaxpayerNumber { get; init; }
+
+ ///
+ /// 发票类型(normal/special)。
+ ///
+ public string InvoiceType { get; init; } = "normal";
+
+ ///
+ /// 开票金额。
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// 关联订单号。
+ ///
+ public string OrderNo { get; init; } = string.Empty;
+
+ ///
+ /// 接收邮箱。
+ ///
+ public string? ContactEmail { get; init; }
+
+ ///
+ /// 联系电话。
+ ///
+ public string? ContactPhone { get; init; }
+
+ ///
+ /// 申请备注。
+ ///
+ public string? ApplyRemark { get; init; }
+
+ ///
+ /// 申请时间(可空,默认当前 UTC)。
+ ///
+ public DateTime? AppliedAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/IssueFinanceInvoiceRecordCommand.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/IssueFinanceInvoiceRecordCommand.cs
new file mode 100644
index 0000000..3039b3a
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/IssueFinanceInvoiceRecordCommand.cs
@@ -0,0 +1,25 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
+
+///
+/// 开票命令。
+///
+public sealed class IssueFinanceInvoiceRecordCommand : IRequest
+{
+ ///
+ /// 发票记录 ID。
+ ///
+ public long RecordId { get; init; }
+
+ ///
+ /// 接收邮箱(可选,传入会覆盖原值)。
+ ///
+ public string? ContactEmail { get; init; }
+
+ ///
+ /// 开票备注。
+ ///
+ public string? IssueRemark { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/SaveFinanceInvoiceSettingCommand.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/SaveFinanceInvoiceSettingCommand.cs
new file mode 100644
index 0000000..e97d30b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/SaveFinanceInvoiceSettingCommand.cs
@@ -0,0 +1,60 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
+
+///
+/// 保存发票设置命令。
+///
+public sealed class SaveFinanceInvoiceSettingCommand : IRequest
+{
+ ///
+ /// 企业名称。
+ ///
+ public string CompanyName { get; init; } = string.Empty;
+
+ ///
+ /// 纳税人识别号。
+ ///
+ public string TaxpayerNumber { get; init; } = string.Empty;
+
+ ///
+ /// 注册地址。
+ ///
+ public string? RegisteredAddress { get; init; }
+
+ ///
+ /// 注册电话。
+ ///
+ public string? RegisteredPhone { get; init; }
+
+ ///
+ /// 开户银行。
+ ///
+ public string? BankName { get; init; }
+
+ ///
+ /// 银行账号。
+ ///
+ public string? BankAccount { get; init; }
+
+ ///
+ /// 是否启用电子普通发票。
+ ///
+ public bool EnableElectronicNormalInvoice { get; init; }
+
+ ///
+ /// 是否启用电子专用发票。
+ ///
+ public bool EnableElectronicSpecialInvoice { get; init; }
+
+ ///
+ /// 是否启用自动开票。
+ ///
+ public bool EnableAutoIssue { get; init; }
+
+ ///
+ /// 自动开票单张最大金额。
+ ///
+ public decimal AutoIssueMaxAmount { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/VoidFinanceInvoiceRecordCommand.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/VoidFinanceInvoiceRecordCommand.cs
new file mode 100644
index 0000000..8b93c0f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Commands/VoidFinanceInvoiceRecordCommand.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
+
+///
+/// 作废发票命令。
+///
+public sealed class VoidFinanceInvoiceRecordCommand : IRequest
+{
+ ///
+ /// 发票记录 ID。
+ ///
+ public long RecordId { get; init; }
+
+ ///
+ /// 作废原因。
+ ///
+ public string VoidReason { get; init; } = string.Empty;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceIssueResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceIssueResultDto.cs
new file mode 100644
index 0000000..de39ed8
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceIssueResultDto.cs
@@ -0,0 +1,47 @@
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+
+///
+/// 发票开票结果 DTO。
+///
+public sealed class FinanceInvoiceIssueResultDto
+{
+ ///
+ /// 记录 ID。
+ ///
+ public long RecordId { get; set; }
+
+ ///
+ /// 发票号码。
+ ///
+ public string InvoiceNo { get; set; } = string.Empty;
+
+ ///
+ /// 开票抬头。
+ ///
+ public string CompanyName { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 接收邮箱。
+ ///
+ public string? ContactEmail { get; set; }
+
+ ///
+ /// 开票时间(UTC)。
+ ///
+ public DateTime IssuedAt { get; set; }
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceRecordDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceRecordDetailDto.cs
new file mode 100644
index 0000000..86f0281
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceRecordDetailDto.cs
@@ -0,0 +1,112 @@
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+
+///
+/// 发票记录详情 DTO。
+///
+public sealed class FinanceInvoiceRecordDetailDto
+{
+ ///
+ /// 记录 ID。
+ ///
+ public long RecordId { get; set; }
+
+ ///
+ /// 发票号码。
+ ///
+ public string InvoiceNo { get; set; } = string.Empty;
+
+ ///
+ /// 申请人。
+ ///
+ public string ApplicantName { get; set; } = string.Empty;
+
+ ///
+ /// 开票抬头(公司名)。
+ ///
+ public string CompanyName { get; set; } = string.Empty;
+
+ ///
+ /// 纳税人识别号。
+ ///
+ public string? TaxpayerNumber { get; set; }
+
+ ///
+ /// 发票类型编码。
+ ///
+ public string InvoiceType { get; set; } = string.Empty;
+
+ ///
+ /// 发票类型文案。
+ ///
+ public string InvoiceTypeText { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 关联订单号。
+ ///
+ public string OrderNo { get; set; } = string.Empty;
+
+ ///
+ /// 接收邮箱。
+ ///
+ public string? ContactEmail { get; set; }
+
+ ///
+ /// 联系电话。
+ ///
+ public string? ContactPhone { get; set; }
+
+ ///
+ /// 申请备注。
+ ///
+ public string? ApplyRemark { get; set; }
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// 申请时间(UTC)。
+ ///
+ public DateTime AppliedAt { get; set; }
+
+ ///
+ /// 开票时间(UTC)。
+ ///
+ public DateTime? IssuedAt { get; set; }
+
+ ///
+ /// 开票人 ID。
+ ///
+ public long? IssuedByUserId { get; set; }
+
+ ///
+ /// 开票备注。
+ ///
+ public string? IssueRemark { get; set; }
+
+ ///
+ /// 作废时间(UTC)。
+ ///
+ public DateTime? VoidedAt { get; set; }
+
+ ///
+ /// 作废人 ID。
+ ///
+ public long? VoidedByUserId { get; set; }
+
+ ///
+ /// 作废原因。
+ ///
+ public string? VoidReason { get; set; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceRecordDto.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceRecordDto.cs
new file mode 100644
index 0000000..afece02
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceRecordDto.cs
@@ -0,0 +1,62 @@
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+
+///
+/// 发票记录列表项 DTO。
+///
+public sealed class FinanceInvoiceRecordDto
+{
+ ///
+ /// 记录 ID。
+ ///
+ public long RecordId { get; set; }
+
+ ///
+ /// 发票号码。
+ ///
+ public string InvoiceNo { get; set; } = string.Empty;
+
+ ///
+ /// 申请人。
+ ///
+ public string ApplicantName { get; set; } = string.Empty;
+
+ ///
+ /// 开票抬头(公司名)。
+ ///
+ public string CompanyName { get; set; } = string.Empty;
+
+ ///
+ /// 发票类型编码。
+ ///
+ public string InvoiceType { get; set; } = string.Empty;
+
+ ///
+ /// 发票类型文案。
+ ///
+ public string InvoiceTypeText { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 关联订单号。
+ ///
+ public string OrderNo { get; set; } = string.Empty;
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// 申请时间(UTC)。
+ ///
+ public DateTime AppliedAt { get; set; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceRecordListResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceRecordListResultDto.cs
new file mode 100644
index 0000000..9aaa4fd
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceRecordListResultDto.cs
@@ -0,0 +1,32 @@
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+
+///
+/// 发票记录分页结果 DTO。
+///
+public sealed class FinanceInvoiceRecordListResultDto
+{
+ ///
+ /// 列表项。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+
+ ///
+ /// 总条数。
+ ///
+ public int TotalCount { get; set; }
+
+ ///
+ /// 统计。
+ ///
+ public FinanceInvoiceStatsDto Stats { get; set; } = new();
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceSettingDto.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceSettingDto.cs
new file mode 100644
index 0000000..0ec7ab8
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceSettingDto.cs
@@ -0,0 +1,57 @@
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+
+///
+/// 发票设置 DTO。
+///
+public sealed class FinanceInvoiceSettingDto
+{
+ ///
+ /// 企业名称。
+ ///
+ public string CompanyName { get; set; } = string.Empty;
+
+ ///
+ /// 纳税人识别号。
+ ///
+ public string TaxpayerNumber { get; set; } = string.Empty;
+
+ ///
+ /// 注册地址。
+ ///
+ public string? RegisteredAddress { get; set; }
+
+ ///
+ /// 注册电话。
+ ///
+ public string? RegisteredPhone { get; set; }
+
+ ///
+ /// 开户银行。
+ ///
+ public string? BankName { get; set; }
+
+ ///
+ /// 银行账号。
+ ///
+ public string? BankAccount { get; set; }
+
+ ///
+ /// 是否启用电子普通发票。
+ ///
+ public bool EnableElectronicNormalInvoice { get; set; }
+
+ ///
+ /// 是否启用电子专用发票。
+ ///
+ public bool EnableElectronicSpecialInvoice { get; set; }
+
+ ///
+ /// 是否启用自动开票。
+ ///
+ public bool EnableAutoIssue { get; set; }
+
+ ///
+ /// 自动开票单张最大金额。
+ ///
+ public decimal AutoIssueMaxAmount { get; set; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceStatsDto.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceStatsDto.cs
new file mode 100644
index 0000000..ccf037b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Dto/FinanceInvoiceStatsDto.cs
@@ -0,0 +1,27 @@
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+
+///
+/// 发票统计 DTO。
+///
+public sealed class FinanceInvoiceStatsDto
+{
+ ///
+ /// 本月已开票金额。
+ ///
+ public decimal CurrentMonthIssuedAmount { get; set; }
+
+ ///
+ /// 本月已开票张数。
+ ///
+ public int CurrentMonthIssuedCount { get; set; }
+
+ ///
+ /// 待开票数量。
+ ///
+ public int PendingCount { get; set; }
+
+ ///
+ /// 已作废数量。
+ ///
+ public int VoidedCount { get; set; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/FinanceInvoiceDtoFactory.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/FinanceInvoiceDtoFactory.cs
new file mode 100644
index 0000000..af65ef8
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/FinanceInvoiceDtoFactory.cs
@@ -0,0 +1,199 @@
+using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice;
+
+///
+/// 发票模块 DTO 构造器。
+///
+internal static class FinanceInvoiceDtoFactory
+{
+ public static FinanceInvoiceSettingDto CreateDefaultSettingDto()
+ {
+ return new FinanceInvoiceSettingDto
+ {
+ CompanyName = string.Empty,
+ TaxpayerNumber = string.Empty,
+ RegisteredAddress = null,
+ RegisteredPhone = null,
+ BankName = null,
+ BankAccount = null,
+ EnableElectronicNormalInvoice = true,
+ EnableElectronicSpecialInvoice = false,
+ EnableAutoIssue = false,
+ AutoIssueMaxAmount = 10_000m
+ };
+ }
+
+ public static FinanceInvoiceSettingDto ToSettingDto(TenantInvoiceSetting source)
+ {
+ return new FinanceInvoiceSettingDto
+ {
+ CompanyName = source.CompanyName,
+ TaxpayerNumber = source.TaxpayerNumber,
+ RegisteredAddress = source.RegisteredAddress,
+ RegisteredPhone = source.RegisteredPhone,
+ BankName = source.BankName,
+ BankAccount = source.BankAccount,
+ EnableElectronicNormalInvoice = source.EnableElectronicNormalInvoice,
+ EnableElectronicSpecialInvoice = source.EnableElectronicSpecialInvoice,
+ EnableAutoIssue = source.EnableAutoIssue,
+ AutoIssueMaxAmount = decimal.Round(source.AutoIssueMaxAmount, 2, MidpointRounding.AwayFromZero)
+ };
+ }
+
+ public static TenantInvoiceSetting CreateSettingEntity(
+ SaveFinanceInvoiceSettingCommand request,
+ string companyName,
+ string taxpayerNumber,
+ string? registeredAddress,
+ string? registeredPhone,
+ string? bankName,
+ string? bankAccount,
+ decimal autoIssueMaxAmount)
+ {
+ return new TenantInvoiceSetting
+ {
+ CompanyName = companyName,
+ TaxpayerNumber = taxpayerNumber,
+ RegisteredAddress = registeredAddress,
+ RegisteredPhone = registeredPhone,
+ BankName = bankName,
+ BankAccount = bankAccount,
+ EnableElectronicNormalInvoice = request.EnableElectronicNormalInvoice,
+ EnableElectronicSpecialInvoice = request.EnableElectronicSpecialInvoice,
+ EnableAutoIssue = request.EnableAutoIssue,
+ AutoIssueMaxAmount = autoIssueMaxAmount
+ };
+ }
+
+ public static void ApplySettingChanges(
+ TenantInvoiceSetting entity,
+ SaveFinanceInvoiceSettingCommand request,
+ string companyName,
+ string taxpayerNumber,
+ string? registeredAddress,
+ string? registeredPhone,
+ string? bankName,
+ string? bankAccount,
+ decimal autoIssueMaxAmount)
+ {
+ entity.CompanyName = companyName;
+ entity.TaxpayerNumber = taxpayerNumber;
+ entity.RegisteredAddress = registeredAddress;
+ entity.RegisteredPhone = registeredPhone;
+ entity.BankName = bankName;
+ entity.BankAccount = bankAccount;
+ entity.EnableElectronicNormalInvoice = request.EnableElectronicNormalInvoice;
+ entity.EnableElectronicSpecialInvoice = request.EnableElectronicSpecialInvoice;
+ entity.EnableAutoIssue = request.EnableAutoIssue;
+ entity.AutoIssueMaxAmount = autoIssueMaxAmount;
+ }
+
+ public static FinanceInvoiceStatsDto ToStatsDto(TenantInvoiceRecordStatsSnapshot source)
+ {
+ return new FinanceInvoiceStatsDto
+ {
+ CurrentMonthIssuedAmount = decimal.Round(source.CurrentMonthIssuedAmount, 2, MidpointRounding.AwayFromZero),
+ CurrentMonthIssuedCount = source.CurrentMonthIssuedCount,
+ PendingCount = source.PendingCount,
+ VoidedCount = source.VoidedCount
+ };
+ }
+
+ public static FinanceInvoiceRecordDto ToRecordDto(TenantInvoiceRecord source)
+ {
+ return new FinanceInvoiceRecordDto
+ {
+ RecordId = source.Id,
+ InvoiceNo = source.InvoiceNo,
+ ApplicantName = source.ApplicantName,
+ CompanyName = source.CompanyName,
+ InvoiceType = FinanceInvoiceMapping.ToInvoiceTypeText(source.InvoiceType),
+ InvoiceTypeText = FinanceInvoiceMapping.ToInvoiceTypeDisplayText(source.InvoiceType),
+ Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
+ OrderNo = source.OrderNo,
+ Status = FinanceInvoiceMapping.ToStatusText(source.Status),
+ StatusText = FinanceInvoiceMapping.ToStatusDisplayText(source.Status),
+ AppliedAt = source.AppliedAt
+ };
+ }
+
+ public static FinanceInvoiceRecordDetailDto ToRecordDetailDto(TenantInvoiceRecord source)
+ {
+ return new FinanceInvoiceRecordDetailDto
+ {
+ RecordId = source.Id,
+ InvoiceNo = source.InvoiceNo,
+ ApplicantName = source.ApplicantName,
+ CompanyName = source.CompanyName,
+ TaxpayerNumber = source.TaxpayerNumber,
+ InvoiceType = FinanceInvoiceMapping.ToInvoiceTypeText(source.InvoiceType),
+ InvoiceTypeText = FinanceInvoiceMapping.ToInvoiceTypeDisplayText(source.InvoiceType),
+ Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
+ OrderNo = source.OrderNo,
+ ContactEmail = source.ContactEmail,
+ ContactPhone = source.ContactPhone,
+ ApplyRemark = source.ApplyRemark,
+ Status = FinanceInvoiceMapping.ToStatusText(source.Status),
+ StatusText = FinanceInvoiceMapping.ToStatusDisplayText(source.Status),
+ AppliedAt = source.AppliedAt,
+ IssuedAt = source.IssuedAt,
+ IssuedByUserId = source.IssuedByUserId,
+ IssueRemark = source.IssueRemark,
+ VoidedAt = source.VoidedAt,
+ VoidedByUserId = source.VoidedByUserId,
+ VoidReason = source.VoidReason
+ };
+ }
+
+ public static FinanceInvoiceIssueResultDto ToIssueResultDto(TenantInvoiceRecord source)
+ {
+ return new FinanceInvoiceIssueResultDto
+ {
+ RecordId = source.Id,
+ InvoiceNo = source.InvoiceNo,
+ CompanyName = source.CompanyName,
+ Amount = decimal.Round(source.Amount, 2, MidpointRounding.AwayFromZero),
+ ContactEmail = source.ContactEmail,
+ IssuedAt = source.IssuedAt ?? DateTime.UtcNow,
+ Status = FinanceInvoiceMapping.ToStatusText(source.Status),
+ StatusText = FinanceInvoiceMapping.ToStatusDisplayText(source.Status)
+ };
+ }
+
+ public static TenantInvoiceRecord CreateRecordEntity(
+ long tenantId,
+ string invoiceNo,
+ string applicantName,
+ string companyName,
+ string? taxpayerNumber,
+ TenantInvoiceType invoiceType,
+ decimal amount,
+ string orderNo,
+ string? contactEmail,
+ string? contactPhone,
+ string? applyRemark,
+ DateTime appliedAt)
+ {
+ return new TenantInvoiceRecord
+ {
+ TenantId = tenantId,
+ InvoiceNo = invoiceNo,
+ ApplicantName = applicantName,
+ CompanyName = companyName,
+ TaxpayerNumber = taxpayerNumber,
+ InvoiceType = invoiceType,
+ Amount = amount,
+ OrderNo = orderNo,
+ ContactEmail = contactEmail,
+ ContactPhone = contactPhone,
+ ApplyRemark = applyRemark,
+ Status = TenantInvoiceStatus.Pending,
+ AppliedAt = appliedAt
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/FinanceInvoiceMapping.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/FinanceInvoiceMapping.cs
new file mode 100644
index 0000000..3f426a7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/FinanceInvoiceMapping.cs
@@ -0,0 +1,252 @@
+using System.Net.Mail;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice;
+
+///
+/// 发票模块映射与参数标准化。
+///
+internal static class FinanceInvoiceMapping
+{
+ public static TenantInvoiceType ParseInvoiceTypeRequired(string? value)
+ {
+ return ParseInvoiceTypeOptional(value)
+ ?? throw new BusinessException(ErrorCodes.BadRequest, "invoiceType 参数不合法");
+ }
+
+ public static TenantInvoiceType? ParseInvoiceTypeOptional(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ return null;
+ }
+
+ return normalized switch
+ {
+ "normal" => TenantInvoiceType.Normal,
+ "special" => TenantInvoiceType.Special,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "invoiceType 参数不合法")
+ };
+ }
+
+ public static TenantInvoiceStatus? ParseStatusOptional(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ return null;
+ }
+
+ return normalized switch
+ {
+ "pending" => TenantInvoiceStatus.Pending,
+ "issued" => TenantInvoiceStatus.Issued,
+ "voided" => TenantInvoiceStatus.Voided,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
+ };
+ }
+
+ public static string ToInvoiceTypeText(TenantInvoiceType value)
+ {
+ return value switch
+ {
+ TenantInvoiceType.Normal => "normal",
+ TenantInvoiceType.Special => "special",
+ _ => "normal"
+ };
+ }
+
+ public static string ToInvoiceTypeDisplayText(TenantInvoiceType value)
+ {
+ return value switch
+ {
+ TenantInvoiceType.Normal => "普票",
+ TenantInvoiceType.Special => "专票",
+ _ => "普票"
+ };
+ }
+
+ public static string ToStatusText(TenantInvoiceStatus value)
+ {
+ return value switch
+ {
+ TenantInvoiceStatus.Pending => "pending",
+ TenantInvoiceStatus.Issued => "issued",
+ TenantInvoiceStatus.Voided => "voided",
+ _ => "pending"
+ };
+ }
+
+ public static string ToStatusDisplayText(TenantInvoiceStatus value)
+ {
+ return value switch
+ {
+ TenantInvoiceStatus.Pending => "待开票",
+ TenantInvoiceStatus.Issued => "已开票",
+ TenantInvoiceStatus.Voided => "已作废",
+ _ => "待开票"
+ };
+ }
+
+ public static string NormalizeCompanyName(string? value)
+ {
+ return NormalizeRequiredText(value, "companyName", 128);
+ }
+
+ public static string NormalizeApplicantName(string? value)
+ {
+ return NormalizeRequiredText(value, "applicantName", 64);
+ }
+
+ public static string NormalizeOrderNo(string? value)
+ {
+ return NormalizeRequiredText(value, "orderNo", 32);
+ }
+
+ public static string NormalizeTaxpayerNumber(string? value)
+ {
+ return NormalizeRequiredText(value, "taxpayerNumber", 64);
+ }
+
+ public static string? NormalizeOptionalTaxpayerNumber(string? value)
+ {
+ return NormalizeOptionalText(value, "taxpayerNumber", 64);
+ }
+
+ public static string? NormalizeOptionalKeyword(string? value)
+ {
+ return NormalizeOptionalText(value, "keyword", 64);
+ }
+
+ public static string? NormalizeOptionalEmail(string? value)
+ {
+ var normalized = NormalizeOptionalText(value, "contactEmail", 128);
+ if (normalized is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ _ = new MailAddress(normalized);
+ return normalized;
+ }
+ catch (FormatException)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "contactEmail 参数不合法");
+ }
+ }
+
+ public static string? NormalizeOptionalPhone(string? value)
+ {
+ return NormalizeOptionalText(value, "contactPhone", 32);
+ }
+
+ public static string? NormalizeOptionalRemark(string? value, string fieldName, int maxLength = 256)
+ {
+ return NormalizeOptionalText(value, fieldName, maxLength);
+ }
+
+ public static string NormalizeVoidReason(string? value)
+ {
+ return NormalizeRequiredText(value, "voidReason", 256);
+ }
+
+ public static decimal NormalizeAmount(decimal value)
+ {
+ if (value <= 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "amount 参数不合法");
+ }
+
+ return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
+ }
+
+ public static decimal NormalizeAutoIssueMaxAmount(decimal value)
+ {
+ if (value <= 0)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "autoIssueMaxAmount 参数不合法");
+ }
+
+ return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
+ }
+
+ public static (DateTime? StartUtc, DateTime? EndUtc) NormalizeDateRange(DateTime? startUtc, DateTime? endUtc)
+ {
+ DateTime? normalizedStart = null;
+ DateTime? normalizedEnd = null;
+
+ if (startUtc.HasValue)
+ {
+ var utcValue = NormalizeUtc(startUtc.Value);
+ normalizedStart = new DateTime(utcValue.Year, utcValue.Month, utcValue.Day, 0, 0, 0, DateTimeKind.Utc);
+ }
+
+ if (endUtc.HasValue)
+ {
+ var utcValue = NormalizeUtc(endUtc.Value);
+ normalizedEnd = new DateTime(utcValue.Year, utcValue.Month, utcValue.Day, 0, 0, 0, DateTimeKind.Utc)
+ .AddDays(1)
+ .AddTicks(-1);
+ }
+
+ if (normalizedStart.HasValue && normalizedEnd.HasValue && normalizedStart > normalizedEnd)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "开始日期不能晚于结束日期");
+ }
+
+ return (normalizedStart, normalizedEnd);
+ }
+
+ public static DateTime NormalizeUtc(DateTime value)
+ {
+ return value.Kind switch
+ {
+ DateTimeKind.Utc => value,
+ DateTimeKind.Local => value.ToUniversalTime(),
+ _ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
+ };
+ }
+
+ public static string BuildInvoiceNo(DateTime nowUtc)
+ {
+ var utcNow = NormalizeUtc(nowUtc);
+ return $"INV{utcNow:yyyyMMddHHmmssfff}{Random.Shared.Next(100, 999)}";
+ }
+
+ private static string NormalizeRequiredText(string? value, string fieldName, int maxLength)
+ {
+ var normalized = (value ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 参数不合法");
+ }
+
+ if (normalized.Length > maxLength)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 {maxLength}");
+ }
+
+ return normalized;
+ }
+
+ private static string? NormalizeOptionalText(string? value, string fieldName, int maxLength)
+ {
+ var normalized = (value ?? string.Empty).Trim();
+ if (normalized.Length == 0)
+ {
+ return null;
+ }
+
+ if (normalized.Length > maxLength)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 {maxLength}");
+ }
+
+ return normalized;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/ApplyFinanceInvoiceRecordCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/ApplyFinanceInvoiceRecordCommandHandler.cs
new file mode 100644
index 0000000..ecd0952
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/ApplyFinanceInvoiceRecordCommandHandler.cs
@@ -0,0 +1,107 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Security;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
+
+///
+/// 申请发票处理器。
+///
+public sealed class ApplyFinanceInvoiceRecordCommandHandler(
+ ITenantInvoiceRepository repository,
+ ITenantProvider tenantProvider,
+ ICurrentUserAccessor currentUserAccessor)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ ApplyFinanceInvoiceRecordCommand request,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var invoiceType = FinanceInvoiceMapping.ParseInvoiceTypeRequired(request.InvoiceType);
+ var applicantName = FinanceInvoiceMapping.NormalizeApplicantName(request.ApplicantName);
+ var companyName = FinanceInvoiceMapping.NormalizeCompanyName(request.CompanyName);
+ var taxpayerNumber = FinanceInvoiceMapping.NormalizeOptionalTaxpayerNumber(request.TaxpayerNumber);
+ var amount = FinanceInvoiceMapping.NormalizeAmount(request.Amount);
+ var orderNo = FinanceInvoiceMapping.NormalizeOrderNo(request.OrderNo);
+ var contactEmail = FinanceInvoiceMapping.NormalizeOptionalEmail(request.ContactEmail);
+ var contactPhone = FinanceInvoiceMapping.NormalizeOptionalPhone(request.ContactPhone);
+ var applyRemark = FinanceInvoiceMapping.NormalizeOptionalRemark(request.ApplyRemark, "applyRemark");
+ var appliedAt = request.AppliedAt.HasValue
+ ? FinanceInvoiceMapping.NormalizeUtc(request.AppliedAt.Value)
+ : DateTime.UtcNow;
+
+ if (invoiceType == TenantInvoiceType.Special && string.IsNullOrWhiteSpace(taxpayerNumber))
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "专票必须填写纳税人识别号");
+ }
+
+ var setting = await repository.GetSettingAsync(tenantId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.BadRequest, "请先完成发票设置");
+ EnsureTypeEnabled(setting, invoiceType);
+
+ var invoiceNo = await GenerateInvoiceNoAsync(tenantId, cancellationToken);
+ var entity = FinanceInvoiceDtoFactory.CreateRecordEntity(
+ tenantId,
+ invoiceNo,
+ applicantName,
+ companyName,
+ taxpayerNumber,
+ invoiceType,
+ amount,
+ orderNo,
+ contactEmail,
+ contactPhone,
+ applyRemark,
+ appliedAt);
+
+ if (setting.EnableAutoIssue && amount <= setting.AutoIssueMaxAmount)
+ {
+ entity.Status = TenantInvoiceStatus.Issued;
+ entity.IssuedAt = DateTime.UtcNow;
+ entity.IssuedByUserId = currentUserAccessor.IsAuthenticated ? currentUserAccessor.UserId : null;
+ entity.IssueRemark = "系统自动开票";
+ }
+
+ await repository.AddRecordAsync(entity, cancellationToken);
+ await repository.SaveChangesAsync(cancellationToken);
+
+ return FinanceInvoiceDtoFactory.ToRecordDetailDto(entity);
+ }
+
+ private static void EnsureTypeEnabled(TenantInvoiceSetting setting, TenantInvoiceType type)
+ {
+ if (type == TenantInvoiceType.Normal && !setting.EnableElectronicNormalInvoice)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "电子普通发票未启用");
+ }
+
+ if (type == TenantInvoiceType.Special && !setting.EnableElectronicSpecialInvoice)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "电子专用发票未启用");
+ }
+ }
+
+ private async Task GenerateInvoiceNoAsync(long tenantId, CancellationToken cancellationToken)
+ {
+ for (var index = 0; index < 10; index += 1)
+ {
+ var invoiceNo = FinanceInvoiceMapping.BuildInvoiceNo(DateTime.UtcNow);
+ var exists = await repository.ExistsInvoiceNoAsync(tenantId, invoiceNo, cancellationToken);
+ if (!exists)
+ {
+ return invoiceNo;
+ }
+ }
+
+ throw new BusinessException(ErrorCodes.BadRequest, "生成发票号码失败,请稍后重试");
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/GetFinanceInvoiceRecordDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/GetFinanceInvoiceRecordDetailQueryHandler.cs
new file mode 100644
index 0000000..5eff6e9
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/GetFinanceInvoiceRecordDetailQueryHandler.cs
@@ -0,0 +1,30 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
+
+///
+/// 发票记录详情查询处理器。
+///
+public sealed class GetFinanceInvoiceRecordDetailQueryHandler(
+ ITenantInvoiceRepository repository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ GetFinanceInvoiceRecordDetailQuery request,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var record = await repository.FindRecordByIdAsync(tenantId, request.RecordId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, "发票记录不存在");
+
+ return FinanceInvoiceDtoFactory.ToRecordDetailDto(record);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/GetFinanceInvoiceRecordListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/GetFinanceInvoiceRecordListQueryHandler.cs
new file mode 100644
index 0000000..952375f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/GetFinanceInvoiceRecordListQueryHandler.cs
@@ -0,0 +1,50 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
+
+///
+/// 发票记录分页查询处理器。
+///
+public sealed class GetFinanceInvoiceRecordListQueryHandler(
+ ITenantInvoiceRepository repository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ GetFinanceInvoiceRecordListQuery request,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var keyword = FinanceInvoiceMapping.NormalizeOptionalKeyword(request.Keyword);
+ var (startUtc, endUtc) = FinanceInvoiceMapping.NormalizeDateRange(request.StartDateUtc, request.EndDateUtc);
+ var page = Math.Max(1, request.Page);
+ var pageSize = Math.Clamp(request.PageSize, 1, 200);
+
+ var (items, totalCount) = await repository.SearchRecordsAsync(
+ tenantId,
+ startUtc,
+ endUtc,
+ request.Status,
+ request.InvoiceType,
+ keyword,
+ page,
+ pageSize,
+ cancellationToken);
+
+ var statsSnapshot = await repository.GetStatsAsync(tenantId, DateTime.UtcNow, cancellationToken);
+
+ return new FinanceInvoiceRecordListResultDto
+ {
+ Items = items.Select(FinanceInvoiceDtoFactory.ToRecordDto).ToList(),
+ Page = page,
+ PageSize = pageSize,
+ TotalCount = totalCount,
+ Stats = FinanceInvoiceDtoFactory.ToStatsDto(statsSnapshot)
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/GetFinanceInvoiceSettingDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/GetFinanceInvoiceSettingDetailQueryHandler.cs
new file mode 100644
index 0000000..c72a8c0
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/GetFinanceInvoiceSettingDetailQueryHandler.cs
@@ -0,0 +1,29 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+using TakeoutSaaS.Application.App.Finance.Invoice.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
+
+///
+/// 发票设置详情查询处理器。
+///
+public sealed class GetFinanceInvoiceSettingDetailQueryHandler(
+ ITenantInvoiceRepository repository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ GetFinanceInvoiceSettingDetailQuery request,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var setting = await repository.GetSettingAsync(tenantId, cancellationToken);
+
+ return setting is null
+ ? FinanceInvoiceDtoFactory.CreateDefaultSettingDto()
+ : FinanceInvoiceDtoFactory.ToSettingDto(setting);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/IssueFinanceInvoiceRecordCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/IssueFinanceInvoiceRecordCommandHandler.cs
new file mode 100644
index 0000000..1fd2f6a
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/IssueFinanceInvoiceRecordCommandHandler.cs
@@ -0,0 +1,65 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Security;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
+
+///
+/// 发票开票处理器。
+///
+public sealed class IssueFinanceInvoiceRecordCommandHandler(
+ ITenantInvoiceRepository repository,
+ ITenantProvider tenantProvider,
+ ICurrentUserAccessor currentUserAccessor)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ IssueFinanceInvoiceRecordCommand request,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var record = await repository.FindRecordByIdAsync(tenantId, request.RecordId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, "发票记录不存在");
+
+ if (record.Status != TenantInvoiceStatus.Pending)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "仅待开票记录允许开票");
+ }
+
+ var setting = await repository.GetSettingAsync(tenantId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.BadRequest, "请先完成发票设置");
+ EnsureTypeEnabled(setting, record.InvoiceType);
+
+ record.ContactEmail = FinanceInvoiceMapping.NormalizeOptionalEmail(request.ContactEmail) ?? record.ContactEmail;
+ record.IssueRemark = FinanceInvoiceMapping.NormalizeOptionalRemark(request.IssueRemark, "issueRemark");
+ record.Status = TenantInvoiceStatus.Issued;
+ record.IssuedAt = DateTime.UtcNow;
+ record.IssuedByUserId = currentUserAccessor.IsAuthenticated ? currentUserAccessor.UserId : null;
+
+ await repository.UpdateRecordAsync(record, cancellationToken);
+ await repository.SaveChangesAsync(cancellationToken);
+
+ return FinanceInvoiceDtoFactory.ToIssueResultDto(record);
+ }
+
+ private static void EnsureTypeEnabled(TenantInvoiceSetting setting, TenantInvoiceType type)
+ {
+ if (type == TenantInvoiceType.Normal && !setting.EnableElectronicNormalInvoice)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "电子普通发票未启用");
+ }
+
+ if (type == TenantInvoiceType.Special && !setting.EnableElectronicSpecialInvoice)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "电子专用发票未启用");
+ }
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/SaveFinanceInvoiceSettingCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/SaveFinanceInvoiceSettingCommandHandler.cs
new file mode 100644
index 0000000..4c196c2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/SaveFinanceInvoiceSettingCommandHandler.cs
@@ -0,0 +1,72 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
+
+///
+/// 保存发票设置处理器。
+///
+public sealed class SaveFinanceInvoiceSettingCommandHandler(
+ ITenantInvoiceRepository repository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ SaveFinanceInvoiceSettingCommand request,
+ CancellationToken cancellationToken)
+ {
+ if (!request.EnableElectronicNormalInvoice && !request.EnableElectronicSpecialInvoice)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "至少启用一种发票类型");
+ }
+
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var companyName = FinanceInvoiceMapping.NormalizeCompanyName(request.CompanyName);
+ var taxpayerNumber = FinanceInvoiceMapping.NormalizeTaxpayerNumber(request.TaxpayerNumber);
+ var registeredAddress = FinanceInvoiceMapping.NormalizeOptionalRemark(request.RegisteredAddress, "registeredAddress", 256);
+ var registeredPhone = FinanceInvoiceMapping.NormalizeOptionalPhone(request.RegisteredPhone);
+ var bankName = FinanceInvoiceMapping.NormalizeOptionalRemark(request.BankName, "bankName", 128);
+ var bankAccount = FinanceInvoiceMapping.NormalizeOptionalRemark(request.BankAccount, "bankAccount", 64);
+ var autoIssueMaxAmount = FinanceInvoiceMapping.NormalizeAutoIssueMaxAmount(request.AutoIssueMaxAmount);
+
+ var setting = await repository.GetSettingAsync(tenantId, cancellationToken);
+ if (setting is null)
+ {
+ setting = FinanceInvoiceDtoFactory.CreateSettingEntity(
+ request,
+ companyName,
+ taxpayerNumber,
+ registeredAddress,
+ registeredPhone,
+ bankName,
+ bankAccount,
+ autoIssueMaxAmount);
+
+ await repository.AddSettingAsync(setting, cancellationToken);
+ }
+ else
+ {
+ FinanceInvoiceDtoFactory.ApplySettingChanges(
+ setting,
+ request,
+ companyName,
+ taxpayerNumber,
+ registeredAddress,
+ registeredPhone,
+ bankName,
+ bankAccount,
+ autoIssueMaxAmount);
+
+ await repository.UpdateSettingAsync(setting, cancellationToken);
+ }
+
+ await repository.SaveChangesAsync(cancellationToken);
+ return FinanceInvoiceDtoFactory.ToSettingDto(setting);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/VoidFinanceInvoiceRecordCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/VoidFinanceInvoiceRecordCommandHandler.cs
new file mode 100644
index 0000000..a30b5cd
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Handlers/VoidFinanceInvoiceRecordCommandHandler.cs
@@ -0,0 +1,46 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Commands;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Security;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Handlers;
+
+///
+/// 发票作废处理器。
+///
+public sealed class VoidFinanceInvoiceRecordCommandHandler(
+ ITenantInvoiceRepository repository,
+ ITenantProvider tenantProvider,
+ ICurrentUserAccessor currentUserAccessor)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ VoidFinanceInvoiceRecordCommand request,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var record = await repository.FindRecordByIdAsync(tenantId, request.RecordId, cancellationToken)
+ ?? throw new BusinessException(ErrorCodes.NotFound, "发票记录不存在");
+
+ if (record.Status != TenantInvoiceStatus.Issued)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "仅已开票记录允许作废");
+ }
+
+ record.Status = TenantInvoiceStatus.Voided;
+ record.VoidReason = FinanceInvoiceMapping.NormalizeVoidReason(request.VoidReason);
+ record.VoidedAt = DateTime.UtcNow;
+ record.VoidedByUserId = currentUserAccessor.IsAuthenticated ? currentUserAccessor.UserId : null;
+
+ await repository.UpdateRecordAsync(record, cancellationToken);
+ await repository.SaveChangesAsync(cancellationToken);
+
+ return FinanceInvoiceDtoFactory.ToRecordDetailDto(record);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Queries/GetFinanceInvoiceRecordDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Queries/GetFinanceInvoiceRecordDetailQuery.cs
new file mode 100644
index 0000000..6df944d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Queries/GetFinanceInvoiceRecordDetailQuery.cs
@@ -0,0 +1,15 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Queries;
+
+///
+/// 查询发票记录详情。
+///
+public sealed class GetFinanceInvoiceRecordDetailQuery : IRequest
+{
+ ///
+ /// 发票记录 ID。
+ ///
+ public long RecordId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Queries/GetFinanceInvoiceRecordListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Queries/GetFinanceInvoiceRecordListQuery.cs
new file mode 100644
index 0000000..6a90ea1
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Queries/GetFinanceInvoiceRecordListQuery.cs
@@ -0,0 +1,46 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Queries;
+
+///
+/// 查询发票记录分页。
+///
+public sealed class GetFinanceInvoiceRecordListQuery : IRequest
+{
+ ///
+ /// 开始日期(UTC)。
+ ///
+ public DateTime? StartDateUtc { get; init; }
+
+ ///
+ /// 结束日期(UTC)。
+ ///
+ public DateTime? EndDateUtc { get; init; }
+
+ ///
+ /// 状态筛选。
+ ///
+ public TenantInvoiceStatus? Status { get; init; }
+
+ ///
+ /// 类型筛选。
+ ///
+ public TenantInvoiceType? InvoiceType { get; init; }
+
+ ///
+ /// 关键词。
+ ///
+ public string? Keyword { get; init; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; init; } = 20;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Queries/GetFinanceInvoiceSettingDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Queries/GetFinanceInvoiceSettingDetailQuery.cs
new file mode 100644
index 0000000..980477b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Invoice/Queries/GetFinanceInvoiceSettingDetailQuery.cs
@@ -0,0 +1,11 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Invoice.Queries;
+
+///
+/// 查询发票设置详情。
+///
+public sealed class GetFinanceInvoiceSettingDetailQuery : IRequest
+{
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Dto/FinanceBusinessReportDtos.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Dto/FinanceBusinessReportDtos.cs
new file mode 100644
index 0000000..bcf431c
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Dto/FinanceBusinessReportDtos.cs
@@ -0,0 +1,218 @@
+namespace TakeoutSaaS.Application.App.Finance.Reports.Dto;
+
+///
+/// 经营报表列表行 DTO。
+///
+public sealed class FinanceBusinessReportListItemDto
+{
+ ///
+ /// 报表 ID。
+ ///
+ public string ReportId { get; set; } = string.Empty;
+
+ ///
+ /// 日期文案。
+ ///
+ public string DateText { get; set; } = string.Empty;
+
+ ///
+ /// 营业额。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 订单数。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageOrderValue { get; set; }
+
+ ///
+ /// 退款率(百分数)。
+ ///
+ public decimal RefundRatePercent { get; set; }
+
+ ///
+ /// 成本总额。
+ ///
+ public decimal CostTotalAmount { get; set; }
+
+ ///
+ /// 净利润。
+ ///
+ public decimal NetProfitAmount { get; set; }
+
+ ///
+ /// 利润率(百分数)。
+ ///
+ public decimal ProfitRatePercent { get; set; }
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// 是否可下载。
+ ///
+ public bool CanDownload { get; set; }
+}
+
+///
+/// 经营报表列表结果 DTO。
+///
+public sealed class FinanceBusinessReportListResultDto
+{
+ ///
+ /// 列表项。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 总数。
+ ///
+ public int Total { get; set; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; set; }
+}
+
+///
+/// 经营报表 KPI DTO。
+///
+public sealed class FinanceBusinessReportKpiDto
+{
+ ///
+ /// 指标键。
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 指标名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 指标值文本。
+ ///
+ public string ValueText { get; set; } = string.Empty;
+
+ ///
+ /// 同比变化率(百分数)。
+ ///
+ public decimal YoyChangeRate { get; set; }
+
+ ///
+ /// 环比变化率(百分数)。
+ ///
+ public decimal MomChangeRate { get; set; }
+}
+
+///
+/// 经营报表明细行 DTO。
+///
+public sealed class FinanceBusinessReportBreakdownItemDto
+{
+ ///
+ /// 明细键。
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 明细名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 占比(百分数)。
+ ///
+ public decimal RatioPercent { get; set; }
+}
+
+///
+/// 经营报表详情 DTO。
+///
+public sealed class FinanceBusinessReportDetailDto
+{
+ ///
+ /// 报表 ID。
+ ///
+ public string ReportId { get; set; } = string.Empty;
+
+ ///
+ /// 标题。
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// 周期类型编码。
+ ///
+ public string PeriodType { get; set; } = string.Empty;
+
+ ///
+ /// 状态编码。
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 状态文案。
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// 关键指标。
+ ///
+ public List Kpis { get; set; } = [];
+
+ ///
+ /// 收入明细(按渠道)。
+ ///
+ public List IncomeBreakdowns { get; set; } = [];
+
+ ///
+ /// 成本明细(按类别)。
+ ///
+ public List CostBreakdowns { get; set; } = [];
+}
+
+///
+/// 经营报表导出 DTO。
+///
+public sealed class FinanceBusinessReportExportDto
+{
+ ///
+ /// 文件名。
+ ///
+ public string FileName { get; set; } = string.Empty;
+
+ ///
+ /// Base64 文件内容。
+ ///
+ public string FileContentBase64 { get; set; } = string.Empty;
+
+ ///
+ /// 总记录数。
+ ///
+ public int TotalCount { get; set; }
+}
+
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportBatchQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportBatchQueryHandler.cs
new file mode 100644
index 0000000..1101089
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportBatchQueryHandler.cs
@@ -0,0 +1,112 @@
+using System.Globalization;
+using System.IO.Compression;
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Domain.Finance.Services;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
+
+///
+/// 经营报表批量导出处理器(ZIP:PDF + Excel)。
+///
+public sealed class ExportFinanceBusinessReportBatchQueryHandler(
+ IFinanceBusinessReportRepository financeBusinessReportRepository,
+ IFinanceBusinessReportExportService financeBusinessReportExportService,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ ExportFinanceBusinessReportBatchQuery request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 读取租户上下文并归一化分页参数。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var normalizedPage = Math.Max(1, request.Page);
+ var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
+
+ // 2. 确保成本配置并补齐快照。
+ await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
+ tenantId,
+ request.StoreId,
+ cancellationToken);
+ await financeBusinessReportRepository.QueueSnapshotsForPageAsync(
+ tenantId,
+ request.StoreId,
+ request.PeriodType,
+ normalizedPage,
+ normalizedPageSize,
+ cancellationToken);
+
+ // 3. 查询导出明细集合(允许实时补算)。
+ var details = await financeBusinessReportRepository.ListBatchDetailsAsync(
+ tenantId,
+ request.StoreId,
+ request.PeriodType,
+ normalizedPage,
+ normalizedPageSize,
+ allowRealtimeBuild: true,
+ cancellationToken);
+
+ // 4. 生成批量 PDF/Excel 并打包 ZIP。
+ var periodCode = FinanceBusinessReportMapping.ToPeriodTypeCode(request.PeriodType);
+ var zipBytes = await CreateZipAsync(
+ details,
+ periodCode,
+ financeBusinessReportExportService,
+ cancellationToken);
+
+ return new FinanceBusinessReportExportDto
+ {
+ FileName = string.Create(
+ CultureInfo.InvariantCulture,
+ $"business-report-batch-{request.StoreId}-{periodCode}-{DateTime.UtcNow:yyyyMMddHHmmss}.zip"),
+ FileContentBase64 = Convert.ToBase64String(zipBytes),
+ TotalCount = details.Count
+ };
+ }
+
+ private static async Task CreateZipAsync(
+ IReadOnlyList details,
+ string periodCode,
+ IFinanceBusinessReportExportService exportService,
+ CancellationToken cancellationToken)
+ {
+ if (details.Count == 0)
+ {
+ using var emptyStream = new MemoryStream();
+ using (var emptyArchive = new ZipArchive(emptyStream, ZipArchiveMode.Create, true))
+ {
+ var entry = emptyArchive.CreateEntry("README.txt");
+ await using var writer = new StreamWriter(entry.Open());
+ await writer.WriteAsync("No business report data in current selection.");
+ }
+
+ return emptyStream.ToArray();
+ }
+
+ var pdfBytes = await exportService.ExportBatchPdfAsync(details, cancellationToken);
+ var excelBytes = await exportService.ExportBatchExcelAsync(details, cancellationToken);
+
+ using var stream = new MemoryStream();
+ using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true))
+ {
+ var pdfEntry = archive.CreateEntry($"business-report-{periodCode}.pdf");
+ await using (var pdfEntryStream = pdfEntry.Open())
+ {
+ await pdfEntryStream.WriteAsync(pdfBytes, cancellationToken);
+ }
+
+ var excelEntry = archive.CreateEntry($"business-report-{periodCode}.xlsx");
+ await using (var excelEntryStream = excelEntry.Open())
+ {
+ await excelEntryStream.WriteAsync(excelBytes, cancellationToken);
+ }
+ }
+
+ return stream.ToArray();
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportExcelQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportExcelQueryHandler.cs
new file mode 100644
index 0000000..8f9fc64
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportExcelQueryHandler.cs
@@ -0,0 +1,59 @@
+using System.Globalization;
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Domain.Finance.Services;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
+
+///
+/// 经营报表 Excel 导出处理器。
+///
+public sealed class ExportFinanceBusinessReportExcelQueryHandler(
+ IFinanceBusinessReportRepository financeBusinessReportRepository,
+ IFinanceBusinessReportExportService financeBusinessReportExportService,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ ExportFinanceBusinessReportExcelQuery request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 读取租户上下文并确保成本配置存在。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
+ tenantId,
+ request.StoreId,
+ cancellationToken);
+
+ // 2. 查询报表详情(允许实时补算)。
+ var detail = await financeBusinessReportRepository.GetDetailAsync(
+ tenantId,
+ request.StoreId,
+ request.ReportId,
+ allowRealtimeBuild: true,
+ cancellationToken);
+
+ if (detail is null)
+ {
+ throw new BusinessException(ErrorCodes.NotFound, "经营报表不存在");
+ }
+
+ // 3. 导出 Excel 并返回 Base64。
+ var fileBytes = await financeBusinessReportExportService.ExportSingleExcelAsync(detail, cancellationToken);
+ var periodCode = FinanceBusinessReportMapping.ToPeriodTypeCode(detail.PeriodType);
+ return new FinanceBusinessReportExportDto
+ {
+ FileName = string.Create(
+ CultureInfo.InvariantCulture,
+ $"business-report-{request.StoreId}-{periodCode}-{request.ReportId}.xlsx"),
+ FileContentBase64 = Convert.ToBase64String(fileBytes),
+ TotalCount = 1
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportPdfQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportPdfQueryHandler.cs
new file mode 100644
index 0000000..2d5eb3c
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/ExportFinanceBusinessReportPdfQueryHandler.cs
@@ -0,0 +1,59 @@
+using System.Globalization;
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Domain.Finance.Services;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
+
+///
+/// 经营报表 PDF 导出处理器。
+///
+public sealed class ExportFinanceBusinessReportPdfQueryHandler(
+ IFinanceBusinessReportRepository financeBusinessReportRepository,
+ IFinanceBusinessReportExportService financeBusinessReportExportService,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ ExportFinanceBusinessReportPdfQuery request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 读取租户上下文并确保成本配置存在。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
+ tenantId,
+ request.StoreId,
+ cancellationToken);
+
+ // 2. 查询报表详情(允许实时补算)。
+ var detail = await financeBusinessReportRepository.GetDetailAsync(
+ tenantId,
+ request.StoreId,
+ request.ReportId,
+ allowRealtimeBuild: true,
+ cancellationToken);
+
+ if (detail is null)
+ {
+ throw new BusinessException(ErrorCodes.NotFound, "经营报表不存在");
+ }
+
+ // 3. 导出 PDF 并返回 Base64。
+ var fileBytes = await financeBusinessReportExportService.ExportSinglePdfAsync(detail, cancellationToken);
+ var periodCode = FinanceBusinessReportMapping.ToPeriodTypeCode(detail.PeriodType);
+ return new FinanceBusinessReportExportDto
+ {
+ FileName = string.Create(
+ CultureInfo.InvariantCulture,
+ $"business-report-{request.StoreId}-{periodCode}-{request.ReportId}.pdf"),
+ FileContentBase64 = Convert.ToBase64String(fileBytes),
+ TotalCount = 1
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/FinanceBusinessReportMapping.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/FinanceBusinessReportMapping.cs
new file mode 100644
index 0000000..a8a6a58
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/FinanceBusinessReportMapping.cs
@@ -0,0 +1,178 @@
+using System.Globalization;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
+
+///
+/// 经营报表映射工具。
+///
+internal static class FinanceBusinessReportMapping
+{
+ ///
+ /// 映射列表行 DTO。
+ ///
+ public static FinanceBusinessReportListItemDto ToListItem(FinanceBusinessReportListItemSnapshot source)
+ {
+ return new FinanceBusinessReportListItemDto
+ {
+ ReportId = source.ReportId.ToString(CultureInfo.InvariantCulture),
+ DateText = FormatPeriodText(source.PeriodType, source.PeriodStartAt, source.PeriodEndAt),
+ RevenueAmount = RoundMoney(source.RevenueAmount),
+ OrderCount = Math.Max(0, source.OrderCount),
+ AverageOrderValue = RoundMoney(source.AverageOrderValue),
+ RefundRatePercent = RoundPercent(source.RefundRate),
+ CostTotalAmount = RoundMoney(source.CostTotalAmount),
+ NetProfitAmount = RoundMoney(source.NetProfitAmount),
+ ProfitRatePercent = RoundPercent(source.ProfitRate),
+ Status = ToStatusCode(source.Status),
+ StatusText = ToStatusText(source.Status),
+ CanDownload = source.Status == FinanceBusinessReportStatus.Succeeded
+ };
+ }
+
+ ///
+ /// 映射详情 DTO。
+ ///
+ public static FinanceBusinessReportDetailDto ToDetail(FinanceBusinessReportDetailSnapshot source)
+ {
+ return new FinanceBusinessReportDetailDto
+ {
+ ReportId = source.ReportId.ToString(CultureInfo.InvariantCulture),
+ Title = BuildTitle(source.PeriodType, source.PeriodStartAt, source.PeriodEndAt),
+ PeriodType = ToPeriodTypeCode(source.PeriodType),
+ Status = ToStatusCode(source.Status),
+ StatusText = ToStatusText(source.Status),
+ Kpis = source.Kpis.Select(ToKpi).ToList(),
+ IncomeBreakdowns = source.IncomeBreakdowns.Select(ToBreakdown).ToList(),
+ CostBreakdowns = source.CostBreakdowns.Select(ToBreakdown).ToList()
+ };
+ }
+
+ ///
+ /// 周期类型编码。
+ ///
+ public static string ToPeriodTypeCode(FinanceBusinessReportPeriodType value)
+ {
+ return value switch
+ {
+ FinanceBusinessReportPeriodType.Daily => "daily",
+ FinanceBusinessReportPeriodType.Weekly => "weekly",
+ FinanceBusinessReportPeriodType.Monthly => "monthly",
+ _ => "daily"
+ };
+ }
+
+ private static FinanceBusinessReportKpiDto ToKpi(FinanceBusinessReportKpiSnapshot source)
+ {
+ return new FinanceBusinessReportKpiDto
+ {
+ Key = source.Key,
+ Label = source.Label,
+ ValueText = FormatKpiValue(source.Key, source.Value),
+ YoyChangeRate = RoundRate(source.YoyChangeRate),
+ MomChangeRate = RoundRate(source.MomChangeRate)
+ };
+ }
+
+ private static FinanceBusinessReportBreakdownItemDto ToBreakdown(FinanceBusinessReportBreakdownSnapshot source)
+ {
+ return new FinanceBusinessReportBreakdownItemDto
+ {
+ Key = source.Key,
+ Label = source.Label,
+ Amount = RoundMoney(source.Amount),
+ RatioPercent = RoundPercent(source.Ratio)
+ };
+ }
+
+ private static string FormatPeriodText(
+ FinanceBusinessReportPeriodType periodType,
+ DateTime startAt,
+ DateTime endAt)
+ {
+ return periodType switch
+ {
+ FinanceBusinessReportPeriodType.Daily => startAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
+ FinanceBusinessReportPeriodType.Weekly =>
+ $"{startAt:MM-dd} ~ {endAt.AddDays(-1):MM-dd}",
+ FinanceBusinessReportPeriodType.Monthly => startAt.ToString("yyyy年M月", CultureInfo.InvariantCulture),
+ _ => startAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
+ };
+ }
+
+ private static string BuildTitle(
+ FinanceBusinessReportPeriodType periodType,
+ DateTime startAt,
+ DateTime endAt)
+ {
+ return periodType switch
+ {
+ FinanceBusinessReportPeriodType.Daily => $"{startAt:yyyy年M月d日} 经营日报",
+ FinanceBusinessReportPeriodType.Weekly => $"{startAt:yyyy年M月d日}~{endAt.AddDays(-1):M月d日} 经营周报",
+ FinanceBusinessReportPeriodType.Monthly => $"{startAt:yyyy年M月} 经营月报",
+ _ => "经营报表"
+ };
+ }
+
+ private static string ToStatusCode(FinanceBusinessReportStatus status)
+ {
+ return status switch
+ {
+ FinanceBusinessReportStatus.Queued => "queued",
+ FinanceBusinessReportStatus.Running => "running",
+ FinanceBusinessReportStatus.Succeeded => "succeeded",
+ FinanceBusinessReportStatus.Failed => "failed",
+ _ => "queued"
+ };
+ }
+
+ private static string ToStatusText(FinanceBusinessReportStatus status)
+ {
+ return status switch
+ {
+ FinanceBusinessReportStatus.Queued => "排队中",
+ FinanceBusinessReportStatus.Running => "生成中",
+ FinanceBusinessReportStatus.Succeeded => "已生成",
+ FinanceBusinessReportStatus.Failed => "生成失败",
+ _ => "排队中"
+ };
+ }
+
+ private static string FormatKpiValue(string key, decimal value)
+ {
+ if (key is "order_count")
+ {
+ return Math.Round(value, 0, MidpointRounding.AwayFromZero).ToString("0", CultureInfo.InvariantCulture);
+ }
+
+ if (key is "refund_rate" or "profit_rate")
+ {
+ return $"{RoundPercent(value):0.##}%";
+ }
+
+ if (key is "average_order_value")
+ {
+ return $"¥{RoundMoney(value):0.##}";
+ }
+
+ return $"¥{RoundMoney(value):0.##}";
+ }
+
+ private static decimal RoundMoney(decimal value)
+ {
+ return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
+ }
+
+ private static decimal RoundPercent(decimal value)
+ {
+ return decimal.Round(value * 100m, 2, MidpointRounding.AwayFromZero);
+ }
+
+ private static decimal RoundRate(decimal value)
+ {
+ return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
+ }
+}
+
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/GetFinanceBusinessReportDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/GetFinanceBusinessReportDetailQueryHandler.cs
new file mode 100644
index 0000000..d3f7b09
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/GetFinanceBusinessReportDetailQueryHandler.cs
@@ -0,0 +1,39 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
+
+///
+/// 经营报表详情查询处理器。
+///
+public sealed class GetFinanceBusinessReportDetailQueryHandler(
+ IFinanceBusinessReportRepository financeBusinessReportRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ GetFinanceBusinessReportDetailQuery request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 读取租户上下文并确保成本配置存在。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
+ tenantId,
+ request.StoreId,
+ cancellationToken);
+
+ // 2. 查询详情(允许实时补算)并映射输出。
+ var detail = await financeBusinessReportRepository.GetDetailAsync(
+ tenantId,
+ request.StoreId,
+ request.ReportId,
+ allowRealtimeBuild: true,
+ cancellationToken);
+
+ return detail is null ? null : FinanceBusinessReportMapping.ToDetail(detail);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/SearchFinanceBusinessReportListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/SearchFinanceBusinessReportListQueryHandler.cs
new file mode 100644
index 0000000..356d3a2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Handlers/SearchFinanceBusinessReportListQueryHandler.cs
@@ -0,0 +1,57 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
+
+///
+/// 经营报表分页查询处理器。
+///
+public sealed class SearchFinanceBusinessReportListQueryHandler(
+ IFinanceBusinessReportRepository financeBusinessReportRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(
+ SearchFinanceBusinessReportListQuery request,
+ CancellationToken cancellationToken)
+ {
+ // 1. 读取租户上下文并归一化分页参数。
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var normalizedPage = Math.Max(1, request.Page);
+ var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
+
+ // 2. 确保成本配置并补齐分页周期快照。
+ await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
+ tenantId,
+ request.StoreId,
+ cancellationToken);
+ await financeBusinessReportRepository.QueueSnapshotsForPageAsync(
+ tenantId,
+ request.StoreId,
+ request.PeriodType,
+ normalizedPage,
+ normalizedPageSize,
+ cancellationToken);
+
+ // 3. 查询分页快照并映射输出。
+ var pageSnapshot = await financeBusinessReportRepository.SearchPageAsync(
+ tenantId,
+ request.StoreId,
+ request.PeriodType,
+ normalizedPage,
+ normalizedPageSize,
+ cancellationToken);
+
+ return new FinanceBusinessReportListResultDto
+ {
+ Items = pageSnapshot.Items.Select(FinanceBusinessReportMapping.ToListItem).ToList(),
+ Total = pageSnapshot.TotalCount,
+ Page = normalizedPage,
+ PageSize = normalizedPageSize
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportBatchQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportBatchQuery.cs
new file mode 100644
index 0000000..94661bf
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportBatchQuery.cs
@@ -0,0 +1,31 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+///
+/// 批量导出经营报表(ZIP:PDF + Excel)。
+///
+public sealed class ExportFinanceBusinessReportBatchQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 周期类型。
+ ///
+ public FinanceBusinessReportPeriodType PeriodType { get; init; } = FinanceBusinessReportPeriodType.Daily;
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; init; } = 20;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportExcelQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportExcelQuery.cs
new file mode 100644
index 0000000..eae96b7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportExcelQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+///
+/// 导出经营报表 Excel。
+///
+public sealed class ExportFinanceBusinessReportExcelQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 报表 ID。
+ ///
+ public long ReportId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportPdfQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportPdfQuery.cs
new file mode 100644
index 0000000..ae1a6ee
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/ExportFinanceBusinessReportPdfQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+///
+/// 导出经营报表 PDF。
+///
+public sealed class ExportFinanceBusinessReportPdfQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 报表 ID。
+ ///
+ public long ReportId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/GetFinanceBusinessReportDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/GetFinanceBusinessReportDetailQuery.cs
new file mode 100644
index 0000000..b6da647
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/GetFinanceBusinessReportDetailQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+///
+/// 查询经营报表详情。
+///
+public sealed class GetFinanceBusinessReportDetailQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 报表 ID。
+ ///
+ public long ReportId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/SearchFinanceBusinessReportListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/SearchFinanceBusinessReportListQuery.cs
new file mode 100644
index 0000000..4d584d9
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Queries/SearchFinanceBusinessReportListQuery.cs
@@ -0,0 +1,31 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Finance.Reports.Dto;
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+///
+/// 查询经营报表分页列表。
+///
+public sealed class SearchFinanceBusinessReportListQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 周期类型。
+ ///
+ public FinanceBusinessReportPeriodType PeriodType { get; init; } = FinanceBusinessReportPeriodType.Daily;
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; init; } = 20;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportBatchQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportBatchQueryValidator.cs
new file mode 100644
index 0000000..d49aa5b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportBatchQueryValidator.cs
@@ -0,0 +1,20 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
+
+///
+/// 经营报表批量导出查询验证器。
+///
+public sealed class ExportFinanceBusinessReportBatchQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public ExportFinanceBusinessReportBatchQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.Page).GreaterThan(0);
+ RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportExcelQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportExcelQueryValidator.cs
new file mode 100644
index 0000000..2c6026a
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportExcelQueryValidator.cs
@@ -0,0 +1,19 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
+
+///
+/// 经营报表 Excel 导出查询验证器。
+///
+public sealed class ExportFinanceBusinessReportExcelQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public ExportFinanceBusinessReportExcelQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.ReportId).GreaterThan(0);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportPdfQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportPdfQueryValidator.cs
new file mode 100644
index 0000000..c17e111
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/ExportFinanceBusinessReportPdfQueryValidator.cs
@@ -0,0 +1,19 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
+
+///
+/// 经营报表 PDF 导出查询验证器。
+///
+public sealed class ExportFinanceBusinessReportPdfQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public ExportFinanceBusinessReportPdfQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.ReportId).GreaterThan(0);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/GetFinanceBusinessReportDetailQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/GetFinanceBusinessReportDetailQueryValidator.cs
new file mode 100644
index 0000000..b5a59c7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/GetFinanceBusinessReportDetailQueryValidator.cs
@@ -0,0 +1,19 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
+
+///
+/// 经营报表详情查询验证器。
+///
+public sealed class GetFinanceBusinessReportDetailQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public GetFinanceBusinessReportDetailQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.ReportId).GreaterThan(0);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/SearchFinanceBusinessReportListQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/SearchFinanceBusinessReportListQueryValidator.cs
new file mode 100644
index 0000000..2894a73
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Finance/Reports/Validators/SearchFinanceBusinessReportListQueryValidator.cs
@@ -0,0 +1,20 @@
+using FluentValidation;
+using TakeoutSaaS.Application.App.Finance.Reports.Queries;
+
+namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
+
+///
+/// 经营报表分页查询验证器。
+///
+public sealed class SearchFinanceBusinessReportListQueryValidator : AbstractValidator
+{
+ ///
+ /// 初始化验证规则。
+ ///
+ public SearchFinanceBusinessReportListQueryValidator()
+ {
+ RuleFor(x => x.StoreId).GreaterThan(0);
+ RuleFor(x => x.Page).GreaterThan(0);
+ RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
+ }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceBusinessReportSnapshot.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceBusinessReportSnapshot.cs
new file mode 100644
index 0000000..a4c3bc4
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceBusinessReportSnapshot.cs
@@ -0,0 +1,111 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Finance.Entities;
+
+///
+/// 经营报表快照实体。
+///
+public sealed class FinanceBusinessReportSnapshot : MultiTenantEntityBase
+{
+ ///
+ /// 所属门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 周期类型。
+ ///
+ public FinanceBusinessReportPeriodType PeriodType { get; set; }
+
+ ///
+ /// 周期开始时间(UTC,含)。
+ ///
+ public DateTime PeriodStartAt { get; set; }
+
+ ///
+ /// 周期结束时间(UTC,不含)。
+ ///
+ public DateTime PeriodEndAt { get; set; }
+
+ ///
+ /// 生成状态。
+ ///
+ public FinanceBusinessReportStatus Status { get; set; } = FinanceBusinessReportStatus.Queued;
+
+ ///
+ /// 营业额。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 订单数。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageOrderValue { get; set; }
+
+ ///
+ /// 退款率(0-1)。
+ ///
+ public decimal RefundRate { get; set; }
+
+ ///
+ /// 成本总额。
+ ///
+ public decimal CostTotalAmount { get; set; }
+
+ ///
+ /// 净利润。
+ ///
+ public decimal NetProfitAmount { get; set; }
+
+ ///
+ /// 利润率(0-1)。
+ ///
+ public decimal ProfitRate { get; set; }
+
+ ///
+ /// KPI 比较快照 JSON(同比/环比)。
+ ///
+ public string KpiComparisonJson { get; set; } = "[]";
+
+ ///
+ /// 收入明细快照 JSON(按渠道)。
+ ///
+ public string IncomeBreakdownJson { get; set; } = "[]";
+
+ ///
+ /// 成本明细快照 JSON(按类别)。
+ ///
+ public string CostBreakdownJson { get; set; } = "[]";
+
+ ///
+ /// 生成开始时间(UTC)。
+ ///
+ public DateTime? StartedAt { get; set; }
+
+ ///
+ /// 生成完成时间(UTC)。
+ ///
+ public DateTime? FinishedAt { get; set; }
+
+ ///
+ /// 最近一次失败信息。
+ ///
+ public string? LastError { get; set; }
+
+ ///
+ /// 重试次数。
+ ///
+ public int RetryCount { get; set; }
+
+ ///
+ /// 调度任务 ID。
+ ///
+ public string? HangfireJobId { get; set; }
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostDailyOverride.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostDailyOverride.cs
new file mode 100644
index 0000000..afae73b
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostDailyOverride.cs
@@ -0,0 +1,36 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Finance.Entities;
+
+///
+/// 成本日覆盖实体。
+///
+public sealed class FinanceCostDailyOverride : MultiTenantEntityBase
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 业务日期(UTC 日期)。
+ ///
+ public DateTime BusinessDate { get; set; }
+
+ ///
+ /// 成本分类。
+ ///
+ public FinanceCostCategory Category { get; set; }
+
+ ///
+ /// 覆盖金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 备注。
+ ///
+ public string? Remark { get; set; }
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostProfile.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostProfile.cs
new file mode 100644
index 0000000..52419e8
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Entities/FinanceCostProfile.cs
@@ -0,0 +1,56 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Finance.Entities;
+
+///
+/// 成本配置实体(类别级规则)。
+///
+public sealed class FinanceCostProfile : MultiTenantEntityBase
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 成本分类。
+ ///
+ public FinanceCostCategory Category { get; set; }
+
+ ///
+ /// 计算模式。
+ ///
+ public FinanceCostCalcMode CalcMode { get; set; }
+
+ ///
+ /// 比例值(0-1,Ratio 模式使用)。
+ ///
+ public decimal Ratio { get; set; }
+
+ ///
+ /// 固定日金额(FixedDaily 模式使用)。
+ ///
+ public decimal FixedDailyAmount { get; set; }
+
+ ///
+ /// 生效开始日期(UTC 日期)。
+ ///
+ public DateTime EffectiveFrom { get; set; }
+
+ ///
+ /// 生效结束日期(UTC 日期,含,null 表示长期)。
+ ///
+ public DateTime? EffectiveTo { get; set; }
+
+ ///
+ /// 是否启用。
+ ///
+ public bool IsEnabled { get; set; } = true;
+
+ ///
+ /// 排序值。
+ ///
+ public int SortOrder { get; set; } = 100;
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportPeriodType.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportPeriodType.cs
new file mode 100644
index 0000000..acb62a7
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportPeriodType.cs
@@ -0,0 +1,23 @@
+namespace TakeoutSaaS.Domain.Finance.Enums;
+
+///
+/// 经营报表周期类型。
+///
+public enum FinanceBusinessReportPeriodType
+{
+ ///
+ /// 日报。
+ ///
+ Daily = 1,
+
+ ///
+ /// 周报。
+ ///
+ Weekly = 2,
+
+ ///
+ /// 月报。
+ ///
+ Monthly = 3
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportStatus.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportStatus.cs
new file mode 100644
index 0000000..71e0b09
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceBusinessReportStatus.cs
@@ -0,0 +1,28 @@
+namespace TakeoutSaaS.Domain.Finance.Enums;
+
+///
+/// 经营报表快照状态。
+///
+public enum FinanceBusinessReportStatus
+{
+ ///
+ /// 已排队。
+ ///
+ Queued = 1,
+
+ ///
+ /// 生成中。
+ ///
+ Running = 2,
+
+ ///
+ /// 已生成。
+ ///
+ Succeeded = 3,
+
+ ///
+ /// 生成失败。
+ ///
+ Failed = 4
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostCalcMode.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostCalcMode.cs
new file mode 100644
index 0000000..d93f2e9
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Enums/FinanceCostCalcMode.cs
@@ -0,0 +1,18 @@
+namespace TakeoutSaaS.Domain.Finance.Enums;
+
+///
+/// 成本计算模式。
+///
+public enum FinanceCostCalcMode
+{
+ ///
+ /// 按营业额比例计算。
+ ///
+ Ratio = 1,
+
+ ///
+ /// 按固定日金额计算。
+ ///
+ FixedDaily = 2
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceBusinessReportSnapshots.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceBusinessReportSnapshots.cs
new file mode 100644
index 0000000..f9eb8c1
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Models/FinanceBusinessReportSnapshots.cs
@@ -0,0 +1,245 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+
+namespace TakeoutSaaS.Domain.Finance.Models;
+
+///
+/// 经营报表 KPI 快照项。
+///
+public sealed class FinanceBusinessReportKpiSnapshot
+{
+ ///
+ /// 指标键。
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 指标名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 指标值。
+ ///
+ public decimal Value { get; set; }
+
+ ///
+ /// 同比变化率(百分数,如 3.5 表示 +3.5%)。
+ ///
+ public decimal YoyChangeRate { get; set; }
+
+ ///
+ /// 环比变化率(百分数,如 2.1 表示 +2.1%)。
+ ///
+ public decimal MomChangeRate { get; set; }
+}
+
+///
+/// 经营报表明细行快照。
+///
+public sealed class FinanceBusinessReportBreakdownSnapshot
+{
+ ///
+ /// 明细键。
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// 明细名称。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 占比(0-1)。
+ ///
+ public decimal Ratio { get; set; }
+}
+
+///
+/// 经营报表列表行快照。
+///
+public sealed class FinanceBusinessReportListItemSnapshot
+{
+ ///
+ /// 报表 ID。
+ ///
+ public long ReportId { get; set; }
+
+ ///
+ /// 周期类型。
+ ///
+ public FinanceBusinessReportPeriodType PeriodType { get; set; }
+
+ ///
+ /// 周期开始时间(UTC,含)。
+ ///
+ public DateTime PeriodStartAt { get; set; }
+
+ ///
+ /// 周期结束时间(UTC,不含)。
+ ///
+ public DateTime PeriodEndAt { get; set; }
+
+ ///
+ /// 状态。
+ ///
+ public FinanceBusinessReportStatus Status { get; set; }
+
+ ///
+ /// 营业额。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 订单数。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageOrderValue { get; set; }
+
+ ///
+ /// 退款率(0-1)。
+ ///
+ public decimal RefundRate { get; set; }
+
+ ///
+ /// 成本总额。
+ ///
+ public decimal CostTotalAmount { get; set; }
+
+ ///
+ /// 净利润。
+ ///
+ public decimal NetProfitAmount { get; set; }
+
+ ///
+ /// 利润率(0-1)。
+ ///
+ public decimal ProfitRate { get; set; }
+}
+
+///
+/// 经营报表分页快照。
+///
+public sealed class FinanceBusinessReportPageSnapshot
+{
+ ///
+ /// 列表。
+ ///
+ public List Items { get; set; } = [];
+
+ ///
+ /// 总数。
+ ///
+ public int TotalCount { get; set; }
+}
+
+///
+/// 经营报表详情快照。
+///
+public sealed class FinanceBusinessReportDetailSnapshot
+{
+ ///
+ /// 报表 ID。
+ ///
+ public long ReportId { get; set; }
+
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; set; }
+
+ ///
+ /// 周期类型。
+ ///
+ public FinanceBusinessReportPeriodType PeriodType { get; set; }
+
+ ///
+ /// 周期开始时间(UTC,含)。
+ ///
+ public DateTime PeriodStartAt { get; set; }
+
+ ///
+ /// 周期结束时间(UTC,不含)。
+ ///
+ public DateTime PeriodEndAt { get; set; }
+
+ ///
+ /// 状态。
+ ///
+ public FinanceBusinessReportStatus Status { get; set; }
+
+ ///
+ /// 营业额。
+ ///
+ public decimal RevenueAmount { get; set; }
+
+ ///
+ /// 订单数。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 客单价。
+ ///
+ public decimal AverageOrderValue { get; set; }
+
+ ///
+ /// 退款率(0-1)。
+ ///
+ public decimal RefundRate { get; set; }
+
+ ///
+ /// 成本总额。
+ ///
+ public decimal CostTotalAmount { get; set; }
+
+ ///
+ /// 净利润。
+ ///
+ public decimal NetProfitAmount { get; set; }
+
+ ///
+ /// 利润率(0-1)。
+ ///
+ public decimal ProfitRate { get; set; }
+
+ ///
+ /// 关键指标快照列表。
+ ///
+ public List Kpis { get; set; } = [];
+
+ ///
+ /// 收入明细(按渠道)。
+ ///
+ public List IncomeBreakdowns { get; set; } = [];
+
+ ///
+ /// 成本明细(按类别)。
+ ///
+ public List CostBreakdowns { get; set; } = [];
+}
+
+///
+/// 待处理报表任务快照。
+///
+public sealed class FinanceBusinessReportPendingSnapshot
+{
+ ///
+ /// 快照 ID。
+ ///
+ public long SnapshotId { get; set; }
+
+ ///
+ /// 租户 ID。
+ ///
+ public long TenantId { get; set; }
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceBusinessReportRepository.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceBusinessReportRepository.cs
new file mode 100644
index 0000000..aed9aee
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Repositories/IFinanceBusinessReportRepository.cs
@@ -0,0 +1,77 @@
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+
+namespace TakeoutSaaS.Domain.Finance.Repositories;
+
+///
+/// 经营报表仓储契约。
+///
+public interface IFinanceBusinessReportRepository
+{
+ ///
+ /// 确保门店存在默认成本配置。
+ ///
+ Task EnsureDefaultCostProfilesAsync(
+ long tenantId,
+ long storeId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 为指定分页周期补齐快照并排队。
+ ///
+ Task QueueSnapshotsForPageAsync(
+ long tenantId,
+ long storeId,
+ FinanceBusinessReportPeriodType periodType,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询经营报表分页结果。
+ ///
+ Task SearchPageAsync(
+ long tenantId,
+ long storeId,
+ FinanceBusinessReportPeriodType periodType,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询经营报表详情。
+ ///
+ Task GetDetailAsync(
+ long tenantId,
+ long storeId,
+ long reportId,
+ bool allowRealtimeBuild,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 查询批量导出详情集合。
+ ///
+ Task> ListBatchDetailsAsync(
+ long tenantId,
+ long storeId,
+ FinanceBusinessReportPeriodType periodType,
+ int page,
+ int pageSize,
+ bool allowRealtimeBuild,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 拉取待处理任务。
+ ///
+ Task> GetPendingSnapshotsAsync(
+ int take,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 执行报表快照生成。
+ ///
+ Task GenerateSnapshotAsync(
+ long snapshotId,
+ CancellationToken cancellationToken = default);
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Finance/Services/IFinanceBusinessReportExportService.cs b/src/Domain/TakeoutSaaS.Domain/Finance/Services/IFinanceBusinessReportExportService.cs
new file mode 100644
index 0000000..9d2f212
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Finance/Services/IFinanceBusinessReportExportService.cs
@@ -0,0 +1,38 @@
+using TakeoutSaaS.Domain.Finance.Models;
+
+namespace TakeoutSaaS.Domain.Finance.Services;
+
+///
+/// 经营报表导出服务契约。
+///
+public interface IFinanceBusinessReportExportService
+{
+ ///
+ /// 导出单条报表 PDF。
+ ///
+ Task ExportSinglePdfAsync(
+ FinanceBusinessReportDetailSnapshot detail,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 导出单条报表 Excel。
+ ///
+ Task ExportSingleExcelAsync(
+ FinanceBusinessReportDetailSnapshot detail,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 导出批量报表 PDF。
+ ///
+ Task ExportBatchPdfAsync(
+ IReadOnlyList details,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 导出批量报表 Excel。
+ ///
+ Task ExportBatchExcelAsync(
+ IReadOnlyList details,
+ CancellationToken cancellationToken = default);
+}
+
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantInvoiceRecord.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantInvoiceRecord.cs
new file mode 100644
index 0000000..bb6acfb
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantInvoiceRecord.cs
@@ -0,0 +1,100 @@
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Tenants.Entities;
+
+///
+/// 租户发票记录。
+///
+public sealed class TenantInvoiceRecord : MultiTenantEntityBase
+{
+ ///
+ /// 发票号码。
+ ///
+ public string InvoiceNo { get; set; } = string.Empty;
+
+ ///
+ /// 申请人。
+ ///
+ public string ApplicantName { get; set; } = string.Empty;
+
+ ///
+ /// 开票抬头(公司名)。
+ ///
+ public string CompanyName { get; set; } = string.Empty;
+
+ ///
+ /// 纳税人识别号快照。
+ ///
+ public string? TaxpayerNumber { get; set; }
+
+ ///
+ /// 发票类型。
+ ///
+ public TenantInvoiceType InvoiceType { get; set; } = TenantInvoiceType.Normal;
+
+ ///
+ /// 开票金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 关联订单号。
+ ///
+ public string OrderNo { get; set; } = string.Empty;
+
+ ///
+ /// 接收邮箱。
+ ///
+ public string? ContactEmail { get; set; }
+
+ ///
+ /// 联系电话。
+ ///
+ public string? ContactPhone { get; set; }
+
+ ///
+ /// 申请备注。
+ ///
+ public string? ApplyRemark { get; set; }
+
+ ///
+ /// 发票状态。
+ ///
+ public TenantInvoiceStatus Status { get; set; } = TenantInvoiceStatus.Pending;
+
+ ///
+ /// 申请时间(UTC)。
+ ///
+ public DateTime AppliedAt { get; set; } = DateTime.UtcNow;
+
+ ///
+ /// 开票时间(UTC)。
+ ///
+ public DateTime? IssuedAt { get; set; }
+
+ ///
+ /// 开票人 ID。
+ ///
+ public long? IssuedByUserId { get; set; }
+
+ ///
+ /// 开票备注。
+ ///
+ public string? IssueRemark { get; set; }
+
+ ///
+ /// 作废时间(UTC)。
+ ///
+ public DateTime? VoidedAt { get; set; }
+
+ ///
+ /// 作废人 ID。
+ ///
+ public long? VoidedByUserId { get; set; }
+
+ ///
+ /// 作废原因。
+ ///
+ public string? VoidReason { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantInvoiceSetting.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantInvoiceSetting.cs
new file mode 100644
index 0000000..0b0ad47
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantInvoiceSetting.cs
@@ -0,0 +1,59 @@
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Tenants.Entities;
+
+///
+/// 租户发票开票基础设置。
+///
+public sealed class TenantInvoiceSetting : MultiTenantEntityBase
+{
+ ///
+ /// 企业名称。
+ ///
+ public string CompanyName { get; set; } = string.Empty;
+
+ ///
+ /// 纳税人识别号。
+ ///
+ public string TaxpayerNumber { get; set; } = string.Empty;
+
+ ///
+ /// 注册地址。
+ ///
+ public string? RegisteredAddress { get; set; }
+
+ ///
+ /// 注册电话。
+ ///
+ public string? RegisteredPhone { get; set; }
+
+ ///
+ /// 开户银行。
+ ///
+ public string? BankName { get; set; }
+
+ ///
+ /// 银行账号。
+ ///
+ public string? BankAccount { get; set; }
+
+ ///
+ /// 是否启用电子普通发票。
+ ///
+ public bool EnableElectronicNormalInvoice { get; set; } = true;
+
+ ///
+ /// 是否启用电子专用发票。
+ ///
+ public bool EnableElectronicSpecialInvoice { get; set; }
+
+ ///
+ /// 是否启用自动开票。
+ ///
+ public bool EnableAutoIssue { get; set; }
+
+ ///
+ /// 自动开票单张最大金额。
+ ///
+ public decimal AutoIssueMaxAmount { get; set; } = 10_000m;
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantInvoiceStatus.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantInvoiceStatus.cs
new file mode 100644
index 0000000..dd3344d
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantInvoiceStatus.cs
@@ -0,0 +1,22 @@
+namespace TakeoutSaaS.Domain.Tenants.Enums;
+
+///
+/// 租户发票状态。
+///
+public enum TenantInvoiceStatus
+{
+ ///
+ /// 待开票。
+ ///
+ Pending = 1,
+
+ ///
+ /// 已开票。
+ ///
+ Issued = 2,
+
+ ///
+ /// 已作废。
+ ///
+ Voided = 3
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantInvoiceType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantInvoiceType.cs
new file mode 100644
index 0000000..d56ceb2
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantInvoiceType.cs
@@ -0,0 +1,17 @@
+namespace TakeoutSaaS.Domain.Tenants.Enums;
+
+///
+/// 租户发票类型。
+///
+public enum TenantInvoiceType
+{
+ ///
+ /// 电子普通发票。
+ ///
+ Normal = 1,
+
+ ///
+ /// 电子专用发票。
+ ///
+ Special = 2
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantInvoiceRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantInvoiceRepository.cs
new file mode 100644
index 0000000..2e51573
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantInvoiceRepository.cs
@@ -0,0 +1,104 @@
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Domain.Tenants.Repositories;
+
+///
+/// 租户发票仓储契约。
+///
+public interface ITenantInvoiceRepository
+{
+ ///
+ /// 查询租户发票设置。
+ ///
+ Task GetSettingAsync(long tenantId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增发票设置。
+ ///
+ Task AddSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default);
+
+ ///
+ /// 更新发票设置。
+ ///
+ Task UpdateSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default);
+
+ ///
+ /// 分页查询发票记录。
+ ///
+ Task<(IReadOnlyList Items, int TotalCount)> SearchRecordsAsync(
+ long tenantId,
+ DateTime? startUtc,
+ DateTime? endUtc,
+ TenantInvoiceStatus? status,
+ TenantInvoiceType? invoiceType,
+ string? keyword,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 获取发票页统计。
+ ///
+ Task GetStatsAsync(
+ long tenantId,
+ DateTime nowUtc,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 根据标识查询发票记录。
+ ///
+ Task FindRecordByIdAsync(
+ long tenantId,
+ long recordId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 判断租户下发票号码是否已存在。
+ ///
+ Task ExistsInvoiceNoAsync(
+ long tenantId,
+ string invoiceNo,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增发票记录。
+ ///
+ Task AddRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default);
+
+ ///
+ /// 更新发票记录。
+ ///
+ Task UpdateRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default);
+
+ ///
+ /// 持久化变更。
+ ///
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
+
+///
+/// 发票页面统计快照。
+///
+public sealed record TenantInvoiceRecordStatsSnapshot
+{
+ ///
+ /// 本月已开票金额。
+ ///
+ public decimal CurrentMonthIssuedAmount { get; init; }
+
+ ///
+ /// 本月已开票张数。
+ ///
+ public int CurrentMonthIssuedCount { get; init; }
+
+ ///
+ /// 待开票张数。
+ ///
+ public int PendingCount { get; init; }
+
+ ///
+ /// 已作废张数。
+ ///
+ public int VoidedCount { get; init; }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
index 0318eab..92a01ab 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Domain.Finance.Services;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Domain.Inventory.Repositories;
@@ -56,6 +57,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -79,6 +81,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
// 2. (空行后) 门店配置服务
services.AddScoped();
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
index ac4bbc9..d6c442d 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
@@ -95,6 +95,26 @@ public sealed class TakeoutAppDbContext(
///
public DbSet TenantVisibilityRoleRules => Set();
///
+ /// 租户发票设置。
+ ///
+ public DbSet TenantInvoiceSettings => Set();
+ ///
+ /// 租户发票记录。
+ ///
+ public DbSet TenantInvoiceRecords => Set();
+ ///
+ /// 经营报表快照。
+ ///
+ public DbSet FinanceBusinessReportSnapshots => Set();
+ ///
+ /// 成本配置。
+ ///
+ public DbSet FinanceCostProfiles => Set();
+ ///
+ /// 成本日覆盖。
+ ///
+ public DbSet FinanceCostDailyOverrides => Set();
+ ///
/// 成本录入汇总。
///
public DbSet FinanceCostEntries => Set();
@@ -534,6 +554,11 @@ public sealed class TakeoutAppDbContext(
ConfigureTenantAnnouncementRead(modelBuilder.Entity());
ConfigureTenantVerificationProfile(modelBuilder.Entity());
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity());
+ ConfigureTenantInvoiceSetting(modelBuilder.Entity());
+ ConfigureTenantInvoiceRecord(modelBuilder.Entity());
+ ConfigureFinanceBusinessReportSnapshot(modelBuilder.Entity());
+ ConfigureFinanceCostProfile(modelBuilder.Entity());
+ ConfigureFinanceCostDailyOverride(modelBuilder.Entity());
ConfigureFinanceCostEntry(modelBuilder.Entity());
ConfigureFinanceCostEntryItem(modelBuilder.Entity());
ConfigureQuotaPackage(modelBuilder.Entity());
@@ -1053,6 +1078,115 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => x.TenantId).IsUnique();
}
+ private static void ConfigureTenantInvoiceSetting(EntityTypeBuilder builder)
+ {
+ builder.ToTable("finance_invoice_settings");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.TenantId).IsRequired();
+ builder.Property(x => x.CompanyName).HasMaxLength(128).IsRequired();
+ builder.Property(x => x.TaxpayerNumber).HasMaxLength(64).IsRequired();
+ builder.Property(x => x.RegisteredAddress).HasMaxLength(256);
+ builder.Property(x => x.RegisteredPhone).HasMaxLength(32);
+ builder.Property(x => x.BankName).HasMaxLength(128);
+ builder.Property(x => x.BankAccount).HasMaxLength(64);
+ builder.Property(x => x.EnableElectronicNormalInvoice).IsRequired();
+ builder.Property(x => x.EnableElectronicSpecialInvoice).IsRequired();
+ builder.Property(x => x.EnableAutoIssue).IsRequired();
+ builder.Property(x => x.AutoIssueMaxAmount).HasPrecision(18, 2).IsRequired();
+
+ builder.HasIndex(x => x.TenantId).IsUnique();
+ }
+
+ private static void ConfigureTenantInvoiceRecord(EntityTypeBuilder builder)
+ {
+ builder.ToTable("finance_invoice_records");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.TenantId).IsRequired();
+ builder.Property(x => x.InvoiceNo).HasMaxLength(32).IsRequired();
+ builder.Property(x => x.ApplicantName).HasMaxLength(64).IsRequired();
+ builder.Property(x => x.CompanyName).HasMaxLength(128).IsRequired();
+ builder.Property(x => x.TaxpayerNumber).HasMaxLength(64);
+ builder.Property(x => x.InvoiceType).HasConversion().IsRequired();
+ builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
+ builder.Property(x => x.OrderNo).HasMaxLength(32).IsRequired();
+ builder.Property(x => x.ContactEmail).HasMaxLength(128);
+ builder.Property(x => x.ContactPhone).HasMaxLength(32);
+ builder.Property(x => x.ApplyRemark).HasMaxLength(256);
+ builder.Property(x => x.Status).HasConversion().IsRequired();
+ builder.Property(x => x.AppliedAt).IsRequired();
+ builder.Property(x => x.IssueRemark).HasMaxLength(256);
+ builder.Property(x => x.VoidReason).HasMaxLength(256);
+
+ builder.HasIndex(x => new { x.TenantId, x.InvoiceNo }).IsUnique();
+ builder.HasIndex(x => new { x.TenantId, x.OrderNo });
+ builder.HasIndex(x => new { x.TenantId, x.Status, x.AppliedAt });
+ builder.HasIndex(x => new { x.TenantId, x.Status, x.IssuedAt });
+ builder.HasIndex(x => new { x.TenantId, x.InvoiceType, x.AppliedAt });
+ }
+
+ private static void ConfigureFinanceBusinessReportSnapshot(EntityTypeBuilder builder)
+ {
+ builder.ToTable("finance_business_report_snapshots");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.TenantId).IsRequired();
+ builder.Property(x => x.StoreId).IsRequired();
+ builder.Property(x => x.PeriodType).HasConversion().IsRequired();
+ builder.Property(x => x.PeriodStartAt).IsRequired();
+ builder.Property(x => x.PeriodEndAt).IsRequired();
+ builder.Property(x => x.Status).HasConversion().IsRequired();
+ builder.Property(x => x.RevenueAmount).HasPrecision(18, 2);
+ builder.Property(x => x.OrderCount).IsRequired();
+ builder.Property(x => x.AverageOrderValue).HasPrecision(18, 2);
+ builder.Property(x => x.RefundRate).HasPrecision(9, 4);
+ builder.Property(x => x.CostTotalAmount).HasPrecision(18, 2);
+ builder.Property(x => x.NetProfitAmount).HasPrecision(18, 2);
+ builder.Property(x => x.ProfitRate).HasPrecision(9, 4);
+ builder.Property(x => x.KpiComparisonJson).HasColumnType("text").IsRequired();
+ builder.Property(x => x.IncomeBreakdownJson).HasColumnType("text").IsRequired();
+ builder.Property(x => x.CostBreakdownJson).HasColumnType("text").IsRequired();
+ builder.Property(x => x.LastError).HasMaxLength(1024);
+ builder.Property(x => x.HangfireJobId).HasMaxLength(64);
+ builder.Property(x => x.RetryCount).HasDefaultValue(0);
+
+ builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PeriodType, x.PeriodStartAt }).IsUnique();
+ builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PeriodType, x.Status, x.PeriodStartAt });
+ builder.HasIndex(x => new { x.TenantId, x.Status, x.CreatedAt });
+ }
+
+ private static void ConfigureFinanceCostProfile(EntityTypeBuilder builder)
+ {
+ builder.ToTable("finance_cost_profiles");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.TenantId).IsRequired();
+ builder.Property(x => x.StoreId).IsRequired();
+ builder.Property(x => x.Category).HasConversion().IsRequired();
+ builder.Property(x => x.CalcMode).HasConversion().IsRequired();
+ builder.Property(x => x.Ratio).HasPrecision(9, 6).IsRequired();
+ builder.Property(x => x.FixedDailyAmount).HasPrecision(18, 2).IsRequired();
+ builder.Property(x => x.EffectiveFrom).IsRequired();
+ builder.Property(x => x.EffectiveTo);
+ builder.Property(x => x.IsEnabled).IsRequired();
+ builder.Property(x => x.SortOrder).HasDefaultValue(100);
+
+ builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Category, x.EffectiveFrom, x.EffectiveTo });
+ builder.HasIndex(x => new { x.TenantId, x.StoreId, x.IsEnabled, x.SortOrder });
+ }
+
+ private static void ConfigureFinanceCostDailyOverride(EntityTypeBuilder builder)
+ {
+ builder.ToTable("finance_cost_daily_overrides");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.TenantId).IsRequired();
+ builder.Property(x => x.StoreId).IsRequired();
+ builder.Property(x => x.BusinessDate).IsRequired();
+ builder.Property(x => x.Category).HasConversion().IsRequired();
+ builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
+ builder.Property(x => x.Remark).HasMaxLength(256);
+
+ builder.HasIndex(x => new { x.TenantId, x.StoreId, x.BusinessDate, x.Category }).IsUnique();
+ builder.HasIndex(x => new { x.TenantId, x.StoreId, x.BusinessDate });
+ }
+
private static void ConfigureFinanceCostEntry(EntityTypeBuilder builder)
{
builder.ToTable("finance_cost_entries");
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceBusinessReportRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceBusinessReportRepository.cs
new file mode 100644
index 0000000..5e8c00b
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceBusinessReportRepository.cs
@@ -0,0 +1,761 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Finance.Entities;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+using TakeoutSaaS.Domain.Finance.Repositories;
+using TakeoutSaaS.Domain.Orders.Enums;
+using TakeoutSaaS.Domain.Payments.Enums;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Infrastructure.App.Repositories;
+
+///
+/// 经营报表 EF Core 仓储实现。
+///
+public sealed class EfFinanceBusinessReportRepository(
+ TakeoutAppDbContext context,
+ ITenantContextAccessor tenantContextAccessor) : IFinanceBusinessReportRepository
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+ private static readonly FinanceCostCategory[] CostCategoryOrder =
+ [
+ FinanceCostCategory.FoodMaterial,
+ FinanceCostCategory.Labor,
+ FinanceCostCategory.FixedExpense,
+ FinanceCostCategory.PackagingConsumable
+ ];
+ private static readonly DeliveryType[] IncomeChannelOrder =
+ [
+ DeliveryType.Delivery,
+ DeliveryType.Pickup,
+ DeliveryType.DineIn
+ ];
+ private static readonly IReadOnlyDictionary DefaultCostProfileMap =
+ new Dictionary
+ {
+ [FinanceCostCategory.FoodMaterial] = (FinanceCostCalcMode.Ratio, 0.36m, 0m),
+ [FinanceCostCategory.Labor] = (FinanceCostCalcMode.Ratio, 0.19m, 0m),
+ [FinanceCostCategory.FixedExpense] = (FinanceCostCalcMode.FixedDaily, 0m, 190m),
+ [FinanceCostCategory.PackagingConsumable] = (FinanceCostCalcMode.Ratio, 0.04m, 0m)
+ };
+
+ ///
+ public async Task EnsureDefaultCostProfilesAsync(long tenantId, long storeId, CancellationToken cancellationToken = default)
+ {
+ if (tenantId <= 0 || storeId <= 0)
+ {
+ return;
+ }
+
+ var existing = await context.FinanceCostProfiles
+ .AsNoTracking()
+ .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null)
+ .Select(item => item.Category)
+ .Distinct()
+ .ToListAsync(cancellationToken);
+ var missing = CostCategoryOrder.Where(item => !existing.Contains(item)).ToList();
+ if (missing.Count == 0)
+ {
+ return;
+ }
+
+ var effectiveFrom = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var entities = missing.Select((category, index) =>
+ {
+ var profile = DefaultCostProfileMap[category];
+ return new FinanceCostProfile
+ {
+ TenantId = tenantId,
+ StoreId = storeId,
+ Category = category,
+ CalcMode = profile.Mode,
+ Ratio = profile.Ratio,
+ FixedDailyAmount = profile.Fixed,
+ EffectiveFrom = effectiveFrom,
+ IsEnabled = true,
+ SortOrder = (index + 1) * 10
+ };
+ }).ToList();
+
+ await context.FinanceCostProfiles.AddRangeAsync(entities, cancellationToken);
+ await context.SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ public async Task QueueSnapshotsForPageAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, CancellationToken cancellationToken = default)
+ {
+ if (tenantId <= 0 || storeId <= 0)
+ {
+ return;
+ }
+
+ var now = DateTime.UtcNow;
+ var periods = BuildPagedPeriods(periodType, page, pageSize, now);
+ var starts = periods.Select(item => item.StartAt).ToHashSet();
+ var existing = await context.FinanceBusinessReportSnapshots
+ .Where(item =>
+ item.TenantId == tenantId &&
+ item.StoreId == storeId &&
+ item.PeriodType == periodType &&
+ item.DeletedAt == null &&
+ starts.Contains(item.PeriodStartAt))
+ .OrderByDescending(item => item.Id)
+ .ToListAsync(cancellationToken);
+ var map = existing.GroupBy(item => item.PeriodStartAt).ToDictionary(group => group.Key, group => group.First());
+
+ var changed = false;
+ foreach (var period in periods)
+ {
+ if (!map.TryGetValue(period.StartAt, out var snapshot))
+ {
+ await context.FinanceBusinessReportSnapshots.AddAsync(new FinanceBusinessReportSnapshot
+ {
+ TenantId = tenantId,
+ StoreId = storeId,
+ PeriodType = periodType,
+ PeriodStartAt = period.StartAt,
+ PeriodEndAt = period.EndAt,
+ Status = FinanceBusinessReportStatus.Queued
+ }, cancellationToken);
+ changed = true;
+ continue;
+ }
+
+ if (snapshot.PeriodEndAt != period.EndAt)
+ {
+ snapshot.PeriodEndAt = period.EndAt;
+ changed = true;
+ }
+
+ if (snapshot.Status == FinanceBusinessReportStatus.Failed && snapshot.RetryCount < 5)
+ {
+ snapshot.Status = FinanceBusinessReportStatus.Queued;
+ snapshot.LastError = null;
+ changed = true;
+ }
+
+ if (now >= period.StartAt
+ && now < period.EndAt
+ && snapshot.Status == FinanceBusinessReportStatus.Succeeded
+ && (!snapshot.FinishedAt.HasValue || snapshot.FinishedAt.Value.AddMinutes(30) <= now))
+ {
+ snapshot.Status = FinanceBusinessReportStatus.Queued;
+ snapshot.StartedAt = null;
+ snapshot.FinishedAt = null;
+ snapshot.LastError = null;
+ changed = true;
+ }
+ }
+
+ if (changed)
+ {
+ await context.SaveChangesAsync(cancellationToken);
+ }
+ }
+
+ ///
+ public async Task SearchPageAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, CancellationToken cancellationToken = default)
+ {
+ var normalizedPage = Math.Max(1, page);
+ var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
+ var query = context.FinanceBusinessReportSnapshots
+ .AsNoTracking()
+ .Where(item =>
+ item.TenantId == tenantId &&
+ item.StoreId == storeId &&
+ item.PeriodType == periodType &&
+ item.DeletedAt == null);
+
+ var totalCount = await query.CountAsync(cancellationToken);
+ if (totalCount == 0)
+ {
+ return new FinanceBusinessReportPageSnapshot();
+ }
+
+ var items = await query
+ .OrderByDescending(item => item.PeriodStartAt)
+ .ThenByDescending(item => item.Id)
+ .Skip((normalizedPage - 1) * normalizedPageSize)
+ .Take(normalizedPageSize)
+ .Select(item => new FinanceBusinessReportListItemSnapshot
+ {
+ ReportId = item.Id,
+ PeriodType = item.PeriodType,
+ PeriodStartAt = item.PeriodStartAt,
+ PeriodEndAt = item.PeriodEndAt,
+ Status = item.Status,
+ RevenueAmount = item.RevenueAmount,
+ OrderCount = item.OrderCount,
+ AverageOrderValue = item.AverageOrderValue,
+ RefundRate = item.RefundRate,
+ CostTotalAmount = item.CostTotalAmount,
+ NetProfitAmount = item.NetProfitAmount,
+ ProfitRate = item.ProfitRate
+ })
+ .ToListAsync(cancellationToken);
+
+ return new FinanceBusinessReportPageSnapshot
+ {
+ Items = items,
+ TotalCount = totalCount
+ };
+ }
+
+ ///
+ public async Task GetDetailAsync(long tenantId, long storeId, long reportId, bool allowRealtimeBuild, CancellationToken cancellationToken = default)
+ {
+ var snapshot = await context.FinanceBusinessReportSnapshots
+ .AsNoTracking()
+ .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == reportId && item.DeletedAt == null, cancellationToken);
+ if (snapshot is null)
+ {
+ return null;
+ }
+
+ if (allowRealtimeBuild && snapshot.Status != FinanceBusinessReportStatus.Succeeded)
+ {
+ await GenerateSnapshotAsync(reportId, cancellationToken);
+ snapshot = await context.FinanceBusinessReportSnapshots
+ .AsNoTracking()
+ .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.StoreId == storeId && item.Id == reportId && item.DeletedAt == null, cancellationToken);
+ if (snapshot is null)
+ {
+ return null;
+ }
+ }
+
+ return new FinanceBusinessReportDetailSnapshot
+ {
+ ReportId = snapshot.Id,
+ StoreId = snapshot.StoreId,
+ PeriodType = snapshot.PeriodType,
+ PeriodStartAt = snapshot.PeriodStartAt,
+ PeriodEndAt = snapshot.PeriodEndAt,
+ Status = snapshot.Status,
+ RevenueAmount = snapshot.RevenueAmount,
+ OrderCount = snapshot.OrderCount,
+ AverageOrderValue = snapshot.AverageOrderValue,
+ RefundRate = snapshot.RefundRate,
+ CostTotalAmount = snapshot.CostTotalAmount,
+ NetProfitAmount = snapshot.NetProfitAmount,
+ ProfitRate = snapshot.ProfitRate,
+ Kpis = Deserialize(snapshot.KpiComparisonJson),
+ IncomeBreakdowns = Deserialize(snapshot.IncomeBreakdownJson),
+ CostBreakdowns = Deserialize(snapshot.CostBreakdownJson)
+ };
+ }
+
+ ///
+ public async Task> ListBatchDetailsAsync(long tenantId, long storeId, FinanceBusinessReportPeriodType periodType, int page, int pageSize, bool allowRealtimeBuild, CancellationToken cancellationToken = default)
+ {
+ var normalizedPage = Math.Max(1, page);
+ var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
+ var reportIds = await context.FinanceBusinessReportSnapshots
+ .AsNoTracking()
+ .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.PeriodType == periodType && item.DeletedAt == null)
+ .OrderByDescending(item => item.PeriodStartAt)
+ .ThenByDescending(item => item.Id)
+ .Skip((normalizedPage - 1) * normalizedPageSize)
+ .Take(normalizedPageSize)
+ .Select(item => item.Id)
+ .ToListAsync(cancellationToken);
+
+ var list = new List(reportIds.Count);
+ foreach (var reportId in reportIds)
+ {
+ var detail = await GetDetailAsync(tenantId, storeId, reportId, allowRealtimeBuild, cancellationToken);
+ if (detail is not null)
+ {
+ list.Add(detail);
+ }
+ }
+
+ return list;
+ }
+
+ ///
+ public async Task> GetPendingSnapshotsAsync(int take, CancellationToken cancellationToken = default)
+ {
+ var normalizedTake = Math.Clamp(take, 1, 200);
+ if ((tenantContextAccessor.Current?.TenantId ?? 0) != 0)
+ {
+ return await QueryPendingAsync(normalizedTake, cancellationToken);
+ }
+
+ var tenantIds = await context.Tenants.AsNoTracking().Where(item => item.DeletedAt == null && item.Id > 0).Select(item => item.Id).ToListAsync(cancellationToken);
+ var pending = new List<(long SnapshotId, long TenantId, DateTime CreatedAt)>();
+ foreach (var tenantId in tenantIds)
+ {
+ using (tenantContextAccessor.EnterTenantScope(tenantId, "finance-report"))
+ {
+ var rows = await context.FinanceBusinessReportSnapshots
+ .AsNoTracking()
+ .Where(item =>
+ item.DeletedAt == null &&
+ (item.Status == FinanceBusinessReportStatus.Queued || (item.Status == FinanceBusinessReportStatus.Failed && item.RetryCount < 3)))
+ .OrderBy(item => item.CreatedAt)
+ .ThenBy(item => item.Id)
+ .Take(normalizedTake)
+ .Select(item => new { item.Id, item.TenantId, item.CreatedAt })
+ .ToListAsync(cancellationToken);
+ pending.AddRange(rows.Select(item => (item.Id, item.TenantId, item.CreatedAt)));
+ }
+ }
+
+ return pending.OrderBy(item => item.CreatedAt).ThenBy(item => item.SnapshotId).Take(normalizedTake).Select(item => new FinanceBusinessReportPendingSnapshot { SnapshotId = item.SnapshotId, TenantId = item.TenantId }).ToList();
+ }
+
+ ///
+ public async Task GenerateSnapshotAsync(long snapshotId, CancellationToken cancellationToken = default)
+ {
+ var snapshot = await context.FinanceBusinessReportSnapshots.FirstOrDefaultAsync(item => item.Id == snapshotId && item.DeletedAt == null, cancellationToken);
+ if (snapshot is null)
+ {
+ return;
+ }
+
+ if (snapshot.Status == FinanceBusinessReportStatus.Running
+ && snapshot.StartedAt.HasValue
+ && snapshot.StartedAt.Value.AddMinutes(10) > DateTime.UtcNow)
+ {
+ return;
+ }
+
+ snapshot.Status = FinanceBusinessReportStatus.Running;
+ snapshot.StartedAt = DateTime.UtcNow;
+ snapshot.LastError = null;
+ await context.SaveChangesAsync(cancellationToken);
+
+ try
+ {
+ await EnsureDefaultCostProfilesAsync(snapshot.TenantId, snapshot.StoreId, cancellationToken);
+ var report = await BuildComputedSnapshotAsync(snapshot.TenantId, snapshot.StoreId, snapshot.PeriodType, snapshot.PeriodStartAt, snapshot.PeriodEndAt, cancellationToken);
+ snapshot.RevenueAmount = report.RevenueAmount;
+ snapshot.OrderCount = report.OrderCount;
+ snapshot.AverageOrderValue = report.AverageOrderValue;
+ snapshot.RefundRate = report.RefundRate;
+ snapshot.CostTotalAmount = report.CostTotalAmount;
+ snapshot.NetProfitAmount = report.NetProfitAmount;
+ snapshot.ProfitRate = report.ProfitRate;
+ snapshot.KpiComparisonJson = JsonSerializer.Serialize(report.Kpis, JsonOptions);
+ snapshot.IncomeBreakdownJson = JsonSerializer.Serialize(report.IncomeBreakdowns, JsonOptions);
+ snapshot.CostBreakdownJson = JsonSerializer.Serialize(report.CostBreakdowns, JsonOptions);
+ snapshot.Status = FinanceBusinessReportStatus.Succeeded;
+ snapshot.FinishedAt = DateTime.UtcNow;
+ snapshot.LastError = null;
+ }
+ catch (Exception ex)
+ {
+ snapshot.Status = FinanceBusinessReportStatus.Failed;
+ snapshot.FinishedAt = DateTime.UtcNow;
+ snapshot.RetryCount += 1;
+ snapshot.LastError = ex.Message[..Math.Min(1024, ex.Message.Length)];
+ }
+
+ await context.SaveChangesAsync(cancellationToken);
+ }
+
+ private async Task> QueryPendingAsync(int take, CancellationToken cancellationToken)
+ {
+ return await context.FinanceBusinessReportSnapshots
+ .AsNoTracking()
+ .Where(item =>
+ item.DeletedAt == null &&
+ (item.Status == FinanceBusinessReportStatus.Queued
+ || (item.Status == FinanceBusinessReportStatus.Failed && item.RetryCount < 3)))
+ .OrderBy(item => item.CreatedAt)
+ .ThenBy(item => item.Id)
+ .Take(take)
+ .Select(item => new FinanceBusinessReportPendingSnapshot
+ {
+ SnapshotId = item.Id,
+ TenantId = item.TenantId
+ })
+ .ToListAsync(cancellationToken);
+ }
+
+ private async Task BuildComputedSnapshotAsync(
+ long tenantId,
+ long storeId,
+ FinanceBusinessReportPeriodType periodType,
+ DateTime startAt,
+ DateTime endAt,
+ CancellationToken cancellationToken)
+ {
+ var current = await BuildRawMetricsAsync(tenantId, storeId, startAt, endAt, cancellationToken);
+ var previous = ResolvePreviousPeriod(periodType, startAt, endAt);
+ var yearAgo = (startAt.AddYears(-1), endAt.AddYears(-1));
+ var mom = await BuildRawMetricsAsync(tenantId, storeId, previous.StartAt, previous.EndAt, cancellationToken);
+ var yoy = await BuildRawMetricsAsync(tenantId, storeId, yearAgo.Item1, yearAgo.Item2, cancellationToken);
+
+ return current with
+ {
+ Kpis = BuildKpis(current, mom, yoy)
+ };
+ }
+
+ private async Task BuildRawMetricsAsync(
+ long tenantId,
+ long storeId,
+ DateTime startAt,
+ DateTime endAt,
+ CancellationToken cancellationToken)
+ {
+ var summary = await QueryRevenueSummaryAsync(tenantId, storeId, startAt, endAt, cancellationToken);
+ var averageOrderValue = summary.OrderCount <= 0 ? 0m : RoundMoney(summary.RevenueAmount / summary.OrderCount);
+ var refundRate = summary.OrderCount <= 0 ? 0m : RoundRatio((decimal)summary.RefundOrderCount / summary.OrderCount);
+ var incomeBreakdowns = await QueryIncomeBreakdownsAsync(tenantId, storeId, startAt, endAt, summary.RevenueAmount, cancellationToken);
+ var dailyRevenueMap = await QueryDailyRevenueMapAsync(tenantId, storeId, startAt, endAt, cancellationToken);
+ var costBreakdowns = await BuildCostBreakdownsAsync(tenantId, storeId, startAt, endAt, dailyRevenueMap, cancellationToken);
+ var costTotalAmount = RoundMoney(costBreakdowns.Sum(item => item.Amount));
+ var netProfitAmount = RoundMoney(summary.RevenueAmount - costTotalAmount);
+ var profitRate = summary.RevenueAmount <= 0 ? 0m : RoundRatio(netProfitAmount / summary.RevenueAmount);
+
+ return new ComputedReportSnapshot
+ {
+ RevenueAmount = summary.RevenueAmount,
+ OrderCount = summary.OrderCount,
+ AverageOrderValue = averageOrderValue,
+ RefundRate = refundRate,
+ CostTotalAmount = costTotalAmount,
+ NetProfitAmount = netProfitAmount,
+ ProfitRate = profitRate,
+ Kpis = [],
+ IncomeBreakdowns = incomeBreakdowns,
+ CostBreakdowns = costBreakdowns
+ };
+ }
+
+ private async Task<(decimal RevenueAmount, int OrderCount, int RefundOrderCount)> QueryRevenueSummaryAsync(
+ long tenantId,
+ long storeId,
+ DateTime startAt,
+ DateTime endAt,
+ CancellationToken cancellationToken)
+ {
+ var paidBaseQuery =
+ from payment in context.PaymentRecords.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on payment.OrderId equals order.Id
+ where payment.TenantId == tenantId
+ && order.TenantId == tenantId
+ && order.StoreId == storeId
+ && payment.Status == PaymentStatus.Paid
+ && (payment.PaidAt ?? payment.CreatedAt) >= startAt
+ && (payment.PaidAt ?? payment.CreatedAt) < endAt
+ select new { payment.Amount, payment.OrderId };
+
+ var paidAmount = await paidBaseQuery.Select(item => item.Amount).DefaultIfEmpty(0m).SumAsync(cancellationToken);
+ var orderCount = await paidBaseQuery.Select(item => item.OrderId).Distinct().CountAsync(cancellationToken);
+
+ var refundBaseQuery =
+ from refund in context.PaymentRefundRecords.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on refund.OrderId equals order.Id
+ where refund.TenantId == tenantId
+ && order.TenantId == tenantId
+ && order.StoreId == storeId
+ && refund.Status == PaymentRefundStatus.Succeeded
+ && (refund.CompletedAt ?? refund.RequestedAt) >= startAt
+ && (refund.CompletedAt ?? refund.RequestedAt) < endAt
+ select new { refund.Amount, refund.OrderId };
+
+ var refundAmount = await refundBaseQuery.Select(item => item.Amount).DefaultIfEmpty(0m).SumAsync(cancellationToken);
+ var refundOrderCount = await refundBaseQuery.Select(item => item.OrderId).Distinct().CountAsync(cancellationToken);
+ return (RoundMoney(paidAmount - refundAmount), orderCount, refundOrderCount);
+ }
+
+ private async Task> QueryDailyRevenueMapAsync(
+ long tenantId,
+ long storeId,
+ DateTime startAt,
+ DateTime endAt,
+ CancellationToken cancellationToken)
+ {
+ var paidRows = await (
+ from payment in context.PaymentRecords.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on payment.OrderId equals order.Id
+ where payment.TenantId == tenantId
+ && order.TenantId == tenantId
+ && order.StoreId == storeId
+ && payment.Status == PaymentStatus.Paid
+ && (payment.PaidAt ?? payment.CreatedAt) >= startAt
+ && (payment.PaidAt ?? payment.CreatedAt) < endAt
+ group payment by (payment.PaidAt ?? payment.CreatedAt).Date into grouped
+ select new { BusinessDate = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
+ .ToListAsync(cancellationToken);
+
+ var refundRows = await (
+ from refund in context.PaymentRefundRecords.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on refund.OrderId equals order.Id
+ where refund.TenantId == tenantId
+ && order.TenantId == tenantId
+ && order.StoreId == storeId
+ && refund.Status == PaymentRefundStatus.Succeeded
+ && (refund.CompletedAt ?? refund.RequestedAt) >= startAt
+ && (refund.CompletedAt ?? refund.RequestedAt) < endAt
+ group refund by (refund.CompletedAt ?? refund.RequestedAt).Date into grouped
+ select new { BusinessDate = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
+ .ToListAsync(cancellationToken);
+
+ var map = new Dictionary();
+ foreach (var row in paidRows)
+ {
+ var date = ToUtcDate(row.BusinessDate);
+ map[date] = map.GetValueOrDefault(date, 0m) + row.Amount;
+ }
+
+ foreach (var row in refundRows)
+ {
+ var date = ToUtcDate(row.BusinessDate);
+ map[date] = map.GetValueOrDefault(date, 0m) - row.Amount;
+ }
+
+ return map.ToDictionary(item => item.Key, item => RoundMoney(item.Value));
+ }
+
+ private async Task> QueryIncomeBreakdownsAsync(
+ long tenantId,
+ long storeId,
+ DateTime startAt,
+ DateTime endAt,
+ decimal totalRevenue,
+ CancellationToken cancellationToken)
+ {
+ var paidRows = await (
+ from payment in context.PaymentRecords.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on payment.OrderId equals order.Id
+ where payment.TenantId == tenantId
+ && order.TenantId == tenantId
+ && order.StoreId == storeId
+ && payment.Status == PaymentStatus.Paid
+ && (payment.PaidAt ?? payment.CreatedAt) >= startAt
+ && (payment.PaidAt ?? payment.CreatedAt) < endAt
+ group payment by order.DeliveryType into grouped
+ select new { DeliveryType = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
+ .ToListAsync(cancellationToken);
+ var refundRows = await (
+ from refund in context.PaymentRefundRecords.AsNoTracking()
+ join order in context.Orders.AsNoTracking()
+ on refund.OrderId equals order.Id
+ where refund.TenantId == tenantId
+ && order.TenantId == tenantId
+ && order.StoreId == storeId
+ && refund.Status == PaymentRefundStatus.Succeeded
+ && (refund.CompletedAt ?? refund.RequestedAt) >= startAt
+ && (refund.CompletedAt ?? refund.RequestedAt) < endAt
+ group refund by order.DeliveryType into grouped
+ select new { DeliveryType = grouped.Key, Amount = grouped.Sum(item => item.Amount) })
+ .ToListAsync(cancellationToken);
+ var paidMap = paidRows.ToDictionary(item => item.DeliveryType, item => item.Amount);
+ var refundMap = refundRows.ToDictionary(item => item.DeliveryType, item => item.Amount);
+
+ return IncomeChannelOrder.Select(channel =>
+ {
+ var amount = paidMap.GetValueOrDefault(channel, 0m) - refundMap.GetValueOrDefault(channel, 0m);
+ return new FinanceBusinessReportBreakdownSnapshot
+ {
+ Key = channel switch
+ {
+ DeliveryType.Delivery => "delivery",
+ DeliveryType.Pickup => "pickup",
+ DeliveryType.DineIn => "dine_in",
+ _ => "delivery"
+ },
+ Label = channel switch
+ {
+ DeliveryType.Delivery => "外卖",
+ DeliveryType.Pickup => "自提",
+ DeliveryType.DineIn => "堂食",
+ _ => "外卖"
+ },
+ Amount = RoundMoney(amount),
+ Ratio = totalRevenue <= 0 ? 0m : RoundRatio(amount / totalRevenue)
+ };
+ }).ToList();
+ }
+
+ private async Task> BuildCostBreakdownsAsync(
+ long tenantId,
+ long storeId,
+ DateTime startAt,
+ DateTime endAt,
+ IReadOnlyDictionary dailyRevenueMap,
+ CancellationToken cancellationToken)
+ {
+ var profiles = await context.FinanceCostProfiles.AsNoTracking()
+ .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null && item.IsEnabled)
+ .OrderBy(item => item.SortOrder).ThenByDescending(item => item.EffectiveFrom).ToListAsync(cancellationToken);
+ var overrides = await context.FinanceCostDailyOverrides.AsNoTracking()
+ .Where(item => item.TenantId == tenantId && item.StoreId == storeId && item.DeletedAt == null && item.BusinessDate >= startAt.Date && item.BusinessDate < endAt.Date)
+ .ToListAsync(cancellationToken);
+ var overrideMap = overrides.ToDictionary(item => $"{item.BusinessDate:yyyyMMdd}:{(int)item.Category}", item => item.Amount);
+ var categoryAmountMap = CostCategoryOrder.ToDictionary(item => item, _ => 0m);
+
+ for (var businessDay = startAt.Date; businessDay < endAt.Date; businessDay = businessDay.AddDays(1))
+ {
+ var dayRevenue = dailyRevenueMap.GetValueOrDefault(ToUtcDate(businessDay), 0m);
+ foreach (var category in CostCategoryOrder)
+ {
+ var key = $"{businessDay:yyyyMMdd}:{(int)category}";
+ decimal amount;
+ if (overrideMap.TryGetValue(key, out var overrideAmount))
+ {
+ amount = overrideAmount;
+ }
+ else
+ {
+ var profile = profiles.FirstOrDefault(item =>
+ item.Category == category &&
+ item.EffectiveFrom.Date <= businessDay &&
+ (!item.EffectiveTo.HasValue || item.EffectiveTo.Value.Date >= businessDay));
+ var defaults = DefaultCostProfileMap[category];
+ var mode = profile?.CalcMode ?? defaults.Mode;
+ var ratio = profile?.Ratio ?? defaults.Ratio;
+ var fixedDaily = profile?.FixedDailyAmount ?? defaults.Fixed;
+ amount = mode == FinanceCostCalcMode.FixedDaily ? fixedDaily : dayRevenue * Math.Max(0m, ratio);
+ }
+
+ categoryAmountMap[category] += RoundMoney(amount);
+ }
+ }
+
+ var totalCostAmount = categoryAmountMap.Sum(item => item.Value);
+ return CostCategoryOrder.Select(category => new FinanceBusinessReportBreakdownSnapshot
+ {
+ Key = category switch
+ {
+ FinanceCostCategory.FoodMaterial => "food_material",
+ FinanceCostCategory.Labor => "labor",
+ FinanceCostCategory.FixedExpense => "fixed_expense",
+ FinanceCostCategory.PackagingConsumable => "packaging_consumable",
+ _ => "food_material"
+ },
+ Label = category switch
+ {
+ FinanceCostCategory.FoodMaterial => "食材成本",
+ FinanceCostCategory.Labor => "人工成本",
+ FinanceCostCategory.FixedExpense => "固定成本",
+ FinanceCostCategory.PackagingConsumable => "包装成本",
+ _ => "食材成本"
+ },
+ Amount = RoundMoney(categoryAmountMap[category]),
+ Ratio = totalCostAmount <= 0m ? 0m : RoundRatio(categoryAmountMap[category] / totalCostAmount)
+ }).ToList();
+ }
+
+ private static List BuildKpis(ComputedReportSnapshot current, ComputedReportSnapshot mom, ComputedReportSnapshot yoy)
+ {
+ var definitions = new List<(string Key, string Label, decimal Current, decimal PrevMom, decimal PrevYoy)>
+ {
+ ("revenue", "营业额", current.RevenueAmount, mom.RevenueAmount, yoy.RevenueAmount),
+ ("order_count", "订单数", current.OrderCount, mom.OrderCount, yoy.OrderCount),
+ ("average_order_value", "客单价", current.AverageOrderValue, mom.AverageOrderValue, yoy.AverageOrderValue),
+ ("refund_rate", "退款率", current.RefundRate, mom.RefundRate, yoy.RefundRate),
+ ("net_profit", "净利润", current.NetProfitAmount, mom.NetProfitAmount, yoy.NetProfitAmount),
+ ("profit_rate", "利润率", current.ProfitRate, mom.ProfitRate, yoy.ProfitRate)
+ };
+
+ return definitions.Select(item => new FinanceBusinessReportKpiSnapshot
+ {
+ Key = item.Key,
+ Label = item.Label,
+ Value = item.Current,
+ MomChangeRate = CalculateChangeRate(item.Current, item.PrevMom),
+ YoyChangeRate = CalculateChangeRate(item.Current, item.PrevYoy)
+ }).ToList();
+ }
+
+ private static (DateTime StartAt, DateTime EndAt) ResolvePreviousPeriod(FinanceBusinessReportPeriodType periodType, DateTime startAt, DateTime endAt)
+ {
+ return periodType switch
+ {
+ FinanceBusinessReportPeriodType.Daily => (startAt.AddDays(-1), endAt.AddDays(-1)),
+ FinanceBusinessReportPeriodType.Weekly => (startAt.AddDays(-7), endAt.AddDays(-7)),
+ FinanceBusinessReportPeriodType.Monthly => (startAt.AddMonths(-1), endAt.AddMonths(-1)),
+ _ => (startAt.AddDays(-1), endAt.AddDays(-1))
+ };
+ }
+
+ private static List<(DateTime StartAt, DateTime EndAt)> BuildPagedPeriods(FinanceBusinessReportPeriodType periodType, int page, int pageSize, DateTime now)
+ {
+ var normalizedPage = Math.Max(1, page);
+ var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
+ var offsetStart = (normalizedPage - 1) * normalizedPageSize;
+ var today = ToUtcDate(now);
+ var list = new List<(DateTime StartAt, DateTime EndAt)>(normalizedPageSize);
+ for (var index = 0; index < normalizedPageSize; index++)
+ {
+ var offset = offsetStart + index;
+ if (periodType == FinanceBusinessReportPeriodType.Weekly)
+ {
+ var weekStart = GetWeekStart(today).AddDays(-7 * offset);
+ list.Add((weekStart, weekStart.AddDays(7)));
+ }
+ else if (periodType == FinanceBusinessReportPeriodType.Monthly)
+ {
+ var monthStart = new DateTime(today.Year, today.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-offset);
+ list.Add((monthStart, monthStart.AddMonths(1)));
+ }
+ else
+ {
+ var dayStart = today.AddDays(-offset);
+ list.Add((dayStart, dayStart.AddDays(1)));
+ }
+ }
+
+ return list;
+ }
+
+ private static List Deserialize(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return [];
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize>(json, JsonOptions) ?? [];
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ private sealed record ComputedReportSnapshot
+ {
+ public decimal RevenueAmount { get; init; }
+
+ public int OrderCount { get; init; }
+
+ public decimal AverageOrderValue { get; init; }
+
+ public decimal RefundRate { get; init; }
+
+ public decimal CostTotalAmount { get; init; }
+
+ public decimal NetProfitAmount { get; init; }
+
+ public decimal ProfitRate { get; init; }
+
+ public List Kpis { get; init; } = [];
+
+ public List IncomeBreakdowns { get; init; } = [];
+
+ public List CostBreakdowns { get; init; } = [];
+ }
+
+ private static decimal CalculateChangeRate(decimal currentValue, decimal previousValue) => previousValue <= 0m ? (currentValue <= 0m ? 0m : 100m) : RoundRate((currentValue - previousValue) / previousValue * 100m);
+ private static decimal RoundMoney(decimal value) => decimal.Round(value, 2, MidpointRounding.AwayFromZero);
+ private static decimal RoundRate(decimal value) => decimal.Round(value, 2, MidpointRounding.AwayFromZero);
+ private static decimal RoundRatio(decimal value) => decimal.Round(value, 4, MidpointRounding.AwayFromZero);
+ private static DateTime ToUtcDate(DateTime value) => new(value.Year, value.Month, value.Day, 0, 0, 0, DateTimeKind.Utc);
+ private static DateTime GetWeekStart(DateTime date) => date.AddDays(0 - (((int)date.DayOfWeek + 6) % 7));
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantInvoiceRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantInvoiceRepository.cs
new file mode 100644
index 0000000..12edea3
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantInvoiceRepository.cs
@@ -0,0 +1,215 @@
+using Microsoft.EntityFrameworkCore;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+namespace TakeoutSaaS.Infrastructure.App.Repositories;
+
+///
+/// 租户发票仓储 EF Core 实现。
+///
+public sealed class EfTenantInvoiceRepository(TakeoutAppDbContext context) : ITenantInvoiceRepository
+{
+ ///
+ public Task GetSettingAsync(long tenantId, CancellationToken cancellationToken = default)
+ {
+ return context.TenantInvoiceSettings
+ .Where(item => item.TenantId == tenantId)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ public Task AddSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default)
+ {
+ return context.TenantInvoiceSettings.AddAsync(entity, cancellationToken).AsTask();
+ }
+
+ ///
+ public Task UpdateSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default)
+ {
+ context.TenantInvoiceSettings.Update(entity);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task<(IReadOnlyList Items, int TotalCount)> SearchRecordsAsync(
+ long tenantId,
+ DateTime? startUtc,
+ DateTime? endUtc,
+ TenantInvoiceStatus? status,
+ TenantInvoiceType? invoiceType,
+ string? keyword,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default)
+ {
+ var normalizedPage = Math.Max(1, page);
+ var normalizedPageSize = Math.Clamp(pageSize, 1, 500);
+
+ var query = BuildRecordQuery(tenantId, startUtc, endUtc, status, invoiceType, keyword);
+
+ var totalCount = await query.CountAsync(cancellationToken);
+ if (totalCount == 0)
+ {
+ return ([], 0);
+ }
+
+ var items = await query
+ .OrderByDescending(item => item.AppliedAt)
+ .ThenByDescending(item => item.Id)
+ .Skip((normalizedPage - 1) * normalizedPageSize)
+ .Take(normalizedPageSize)
+ .ToListAsync(cancellationToken);
+
+ return (items, totalCount);
+ }
+
+ ///
+ public async Task GetStatsAsync(
+ long tenantId,
+ DateTime nowUtc,
+ CancellationToken cancellationToken = default)
+ {
+ var utcNow = NormalizeUtc(nowUtc);
+ var monthStart = new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ var summary = await context.TenantInvoiceRecords
+ .AsNoTracking()
+ .Where(item => item.TenantId == tenantId)
+ .GroupBy(_ => 1)
+ .Select(group => new
+ {
+ CurrentMonthIssuedAmount = group
+ .Where(item =>
+ item.Status == TenantInvoiceStatus.Issued &&
+ item.IssuedAt.HasValue &&
+ item.IssuedAt.Value >= monthStart &&
+ item.IssuedAt.Value <= utcNow)
+ .Sum(item => item.Amount),
+ CurrentMonthIssuedCount = group
+ .Count(item =>
+ item.Status == TenantInvoiceStatus.Issued &&
+ item.IssuedAt.HasValue &&
+ item.IssuedAt.Value >= monthStart &&
+ item.IssuedAt.Value <= utcNow),
+ PendingCount = group.Count(item => item.Status == TenantInvoiceStatus.Pending),
+ VoidedCount = group.Count(item => item.Status == TenantInvoiceStatus.Voided)
+ })
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (summary is null)
+ {
+ return new TenantInvoiceRecordStatsSnapshot();
+ }
+
+ return new TenantInvoiceRecordStatsSnapshot
+ {
+ CurrentMonthIssuedAmount = summary.CurrentMonthIssuedAmount,
+ CurrentMonthIssuedCount = summary.CurrentMonthIssuedCount,
+ PendingCount = summary.PendingCount,
+ VoidedCount = summary.VoidedCount
+ };
+ }
+
+ ///
+ public Task FindRecordByIdAsync(
+ long tenantId,
+ long recordId,
+ CancellationToken cancellationToken = default)
+ {
+ return context.TenantInvoiceRecords
+ .Where(item => item.TenantId == tenantId && item.Id == recordId)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ public Task ExistsInvoiceNoAsync(
+ long tenantId,
+ string invoiceNo,
+ CancellationToken cancellationToken = default)
+ {
+ return context.TenantInvoiceRecords
+ .AsNoTracking()
+ .AnyAsync(
+ item => item.TenantId == tenantId && item.InvoiceNo == invoiceNo,
+ cancellationToken);
+ }
+
+ ///
+ public Task AddRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default)
+ {
+ return context.TenantInvoiceRecords.AddAsync(entity, cancellationToken).AsTask();
+ }
+
+ ///
+ public Task UpdateRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default)
+ {
+ context.TenantInvoiceRecords.Update(entity);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ return context.SaveChangesAsync(cancellationToken);
+ }
+
+ private IQueryable BuildRecordQuery(
+ long tenantId,
+ DateTime? startUtc,
+ DateTime? endUtc,
+ TenantInvoiceStatus? status,
+ TenantInvoiceType? invoiceType,
+ string? keyword)
+ {
+ var query = context.TenantInvoiceRecords
+ .AsNoTracking()
+ .Where(item => item.TenantId == tenantId);
+
+ if (startUtc.HasValue)
+ {
+ var normalizedStart = NormalizeUtc(startUtc.Value);
+ query = query.Where(item => item.AppliedAt >= normalizedStart);
+ }
+
+ if (endUtc.HasValue)
+ {
+ var normalizedEnd = NormalizeUtc(endUtc.Value);
+ query = query.Where(item => item.AppliedAt <= normalizedEnd);
+ }
+
+ if (status.HasValue)
+ {
+ query = query.Where(item => item.Status == status.Value);
+ }
+
+ if (invoiceType.HasValue)
+ {
+ query = query.Where(item => item.InvoiceType == invoiceType.Value);
+ }
+
+ var normalizedKeyword = (keyword ?? string.Empty).Trim();
+ if (!string.IsNullOrWhiteSpace(normalizedKeyword))
+ {
+ var like = $"%{normalizedKeyword}%";
+ query = query.Where(item =>
+ EF.Functions.ILike(item.InvoiceNo, like) ||
+ EF.Functions.ILike(item.CompanyName, like) ||
+ EF.Functions.ILike(item.ApplicantName, like) ||
+ EF.Functions.ILike(item.OrderNo, like));
+ }
+
+ return query;
+ }
+
+ private static DateTime NormalizeUtc(DateTime value)
+ {
+ return value.Kind switch
+ {
+ DateTimeKind.Utc => value,
+ DateTimeKind.Local => value.ToUniversalTime(),
+ _ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
+ };
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/FinanceBusinessReportExportService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/FinanceBusinessReportExportService.cs
new file mode 100644
index 0000000..8e42e81
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/FinanceBusinessReportExportService.cs
@@ -0,0 +1,303 @@
+using ClosedXML.Excel;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+using System.Globalization;
+using TakeoutSaaS.Domain.Finance.Enums;
+using TakeoutSaaS.Domain.Finance.Models;
+using TakeoutSaaS.Domain.Finance.Services;
+
+namespace TakeoutSaaS.Infrastructure.App.Services;
+
+///
+/// 经营报表导出服务实现(PDF / Excel)。
+///
+public sealed class FinanceBusinessReportExportService : IFinanceBusinessReportExportService
+{
+ public FinanceBusinessReportExportService()
+ {
+ QuestPDF.Settings.License = LicenseType.Community;
+ }
+
+ ///
+ public Task ExportSinglePdfAsync(
+ FinanceBusinessReportDetailSnapshot detail,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(detail);
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(BuildPdf([detail]));
+ }
+
+ ///
+ public Task ExportSingleExcelAsync(
+ FinanceBusinessReportDetailSnapshot detail,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(detail);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using var workbook = new XLWorkbook();
+ var worksheet = workbook.Worksheets.Add("经营报表");
+ WriteDetailWorksheet(worksheet, detail);
+
+ using var stream = new MemoryStream();
+ workbook.SaveAs(stream);
+ return Task.FromResult(stream.ToArray());
+ }
+
+ ///
+ public Task ExportBatchPdfAsync(
+ IReadOnlyList details,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(details);
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(BuildPdf(details));
+ }
+
+ ///
+ public Task ExportBatchExcelAsync(
+ IReadOnlyList details,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(details);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using var workbook = new XLWorkbook();
+ var summary = workbook.Worksheets.Add("汇总");
+ WriteSummaryWorksheet(summary, details);
+
+ for (var index = 0; index < details.Count; index++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var detail = details[index];
+ var sheetName = $"报表{index + 1:D2}";
+ var worksheet = workbook.Worksheets.Add(sheetName);
+ WriteDetailWorksheet(worksheet, detail);
+ }
+
+ using var stream = new MemoryStream();
+ workbook.SaveAs(stream);
+ return Task.FromResult(stream.ToArray());
+ }
+
+ private static byte[] BuildPdf(IReadOnlyList details)
+ {
+ var source = details.Count == 0
+ ? [new FinanceBusinessReportDetailSnapshot()]
+ : details;
+
+ var document = Document.Create(container =>
+ {
+ foreach (var detail in source)
+ {
+ container.Page(page =>
+ {
+ page.Size(PageSizes.A4);
+ page.Margin(24);
+ page.DefaultTextStyle(x => x.FontSize(10));
+
+ page.Content().Column(column =>
+ {
+ column.Spacing(8);
+ column.Item().Text(BuildTitle(detail)).FontSize(16).SemiBold();
+ column.Item().Text($"状态:{ResolveStatusText(detail.Status)}");
+
+ column.Item().Element(section => BuildKpiSection(section, detail.Kpis));
+ column.Item().Element(section => BuildBreakdownSection(section, "收入明细(按渠道)", detail.IncomeBreakdowns));
+ column.Item().Element(section => BuildBreakdownSection(section, "成本明细(按类别)", detail.CostBreakdowns));
+ });
+ });
+ }
+ });
+
+ return document.GeneratePdf();
+ }
+
+ private static void BuildKpiSection(IContainer container, IReadOnlyList kpis)
+ {
+ container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
+ {
+ column.Spacing(4);
+ column.Item().Text("关键指标").SemiBold();
+
+ if (kpis.Count == 0)
+ {
+ column.Item().Text("暂无数据");
+ return;
+ }
+
+ foreach (var item in kpis)
+ {
+ column.Item().Row(row =>
+ {
+ row.RelativeItem().Text(item.Label);
+ row.RelativeItem().AlignRight().Text(FormatKpiValue(item.Key, item.Value));
+ row.RelativeItem().AlignRight().Text(
+ $"同比 {FormatSignedRate(item.YoyChangeRate)} | 环比 {FormatSignedRate(item.MomChangeRate)}");
+ });
+ }
+ });
+ }
+
+ private static void BuildBreakdownSection(
+ IContainer container,
+ string title,
+ IReadOnlyList rows)
+ {
+ container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
+ {
+ column.Spacing(4);
+ column.Item().Text(title).SemiBold();
+
+ if (rows.Count == 0)
+ {
+ column.Item().Text("暂无数据");
+ return;
+ }
+
+ foreach (var item in rows)
+ {
+ column.Item().Row(row =>
+ {
+ row.RelativeItem().Text(item.Label);
+ row.ConstantItem(80).AlignRight().Text(FormatPercent(item.Ratio));
+ row.ConstantItem(120).AlignRight().Text(FormatCurrency(item.Amount));
+ });
+ }
+ });
+ }
+
+ private static void WriteSummaryWorksheet(
+ IXLWorksheet worksheet,
+ IReadOnlyList details)
+ {
+ worksheet.Cell(1, 1).Value = "报表标题";
+ worksheet.Cell(1, 2).Value = "状态";
+ worksheet.Cell(1, 3).Value = "营业额";
+ worksheet.Cell(1, 4).Value = "订单数";
+ worksheet.Cell(1, 5).Value = "客单价";
+ worksheet.Cell(1, 6).Value = "退款率";
+ worksheet.Cell(1, 7).Value = "成本总额";
+ worksheet.Cell(1, 8).Value = "净利润";
+ worksheet.Cell(1, 9).Value = "利润率";
+
+ for (var index = 0; index < details.Count; index++)
+ {
+ var row = index + 2;
+ var detail = details[index];
+ worksheet.Cell(row, 1).Value = BuildTitle(detail);
+ worksheet.Cell(row, 2).Value = ResolveStatusText(detail.Status);
+ worksheet.Cell(row, 3).Value = detail.RevenueAmount;
+ worksheet.Cell(row, 4).Value = detail.OrderCount;
+ worksheet.Cell(row, 5).Value = detail.AverageOrderValue;
+ worksheet.Cell(row, 6).Value = detail.RefundRate;
+ worksheet.Cell(row, 7).Value = detail.CostTotalAmount;
+ worksheet.Cell(row, 8).Value = detail.NetProfitAmount;
+ worksheet.Cell(row, 9).Value = detail.ProfitRate;
+ }
+
+ worksheet.Columns().AdjustToContents();
+ }
+
+ private static void WriteDetailWorksheet(
+ IXLWorksheet worksheet,
+ FinanceBusinessReportDetailSnapshot detail)
+ {
+ var row = 1;
+
+ worksheet.Cell(row, 1).Value = BuildTitle(detail);
+ worksheet.Range(row, 1, row, 4).Merge().Style.Font.SetBold();
+ row += 2;
+
+ worksheet.Cell(row, 1).Value = "关键指标";
+ worksheet.Cell(row, 1).Style.Font.SetBold();
+ row += 1;
+
+ worksheet.Cell(row, 1).Value = "指标";
+ worksheet.Cell(row, 2).Value = "值";
+ worksheet.Cell(row, 3).Value = "同比";
+ worksheet.Cell(row, 4).Value = "环比";
+ row += 1;
+
+ foreach (var item in detail.Kpis)
+ {
+ worksheet.Cell(row, 1).Value = item.Label;
+ worksheet.Cell(row, 2).Value = FormatKpiValue(item.Key, item.Value);
+ worksheet.Cell(row, 3).Value = FormatSignedRate(item.YoyChangeRate);
+ worksheet.Cell(row, 4).Value = FormatSignedRate(item.MomChangeRate);
+ row += 1;
+ }
+
+ row += 1;
+ row = WriteBreakdownTable(worksheet, row, "收入明细(按渠道)", detail.IncomeBreakdowns);
+ row += 1;
+ _ = WriteBreakdownTable(worksheet, row, "成本明细(按类别)", detail.CostBreakdowns);
+
+ worksheet.Columns().AdjustToContents();
+ }
+
+ private static int WriteBreakdownTable(
+ IXLWorksheet worksheet,
+ int startRow,
+ string title,
+ IReadOnlyList rows)
+ {
+ var row = startRow;
+ worksheet.Cell(row, 1).Value = title;
+ worksheet.Cell(row, 1).Style.Font.SetBold();
+ row += 1;
+
+ worksheet.Cell(row, 1).Value = "名称";
+ worksheet.Cell(row, 2).Value = "占比";
+ worksheet.Cell(row, 3).Value = "金额";
+ row += 1;
+
+ foreach (var item in rows)
+ {
+ worksheet.Cell(row, 1).Value = item.Label;
+ worksheet.Cell(row, 2).Value = FormatPercent(item.Ratio);
+ worksheet.Cell(row, 3).Value = item.Amount;
+ row += 1;
+ }
+
+ return row;
+ }
+
+ private static string FormatCurrency(decimal value) => $"¥{value:0.##}";
+ private static string FormatPercent(decimal ratioValue) => $"{ratioValue * 100m:0.##}%";
+ private static string FormatSignedRate(decimal rate) => $"{(rate >= 0m ? "+" : string.Empty)}{rate:0.##}%";
+ private static string BuildTitle(FinanceBusinessReportDetailSnapshot detail) => detail.PeriodType switch
+ {
+ FinanceBusinessReportPeriodType.Daily => $"{detail.PeriodStartAt:yyyy年M月d日} 经营日报",
+ FinanceBusinessReportPeriodType.Weekly => $"{detail.PeriodStartAt:yyyy年M月d日}~{detail.PeriodEndAt.AddDays(-1):M月d日} 经营周报",
+ FinanceBusinessReportPeriodType.Monthly => $"{detail.PeriodStartAt:yyyy年M月} 经营月报",
+ _ => detail.PeriodStartAt == default
+ ? "经营报表"
+ : detail.PeriodStartAt.ToString("yyyy-MM-dd 经营报表", CultureInfo.InvariantCulture)
+ };
+ private static string ResolveStatusText(FinanceBusinessReportStatus status) => status switch
+ {
+ FinanceBusinessReportStatus.Queued => "排队中",
+ FinanceBusinessReportStatus.Running => "生成中",
+ FinanceBusinessReportStatus.Succeeded => "已生成",
+ FinanceBusinessReportStatus.Failed => "生成失败",
+ _ => "未知"
+ };
+
+ private static string FormatKpiValue(string key, decimal value)
+ {
+ if (key == "order_count")
+ {
+ return $"{decimal.Round(value, 0, MidpointRounding.AwayFromZero):0}";
+ }
+
+ if (key is "refund_rate" or "profit_rate")
+ {
+ return $"{value * 100m:0.##}%";
+ }
+
+ return FormatCurrency(value);
+ }
+}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304071623_AddFinanceInvoiceModule.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304071623_AddFinanceInvoiceModule.Designer.cs
new file mode 100644
index 0000000..e64a535
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260304071623_AddFinanceInvoiceModule.Designer.cs
@@ -0,0 +1,10972 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+#nullable disable
+
+namespace TakeoutSaaS.Infrastructure.Migrations
+{
+ [DbContext(typeof(TakeoutAppDbContext))]
+ [Migration("20260304071623_AddFinanceInvoiceModule")]
+ partial class AddFinanceInvoiceModule
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Consumed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConsumerId")
+ .HasColumnType("uuid");
+
+ b.Property("Delivered")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSequenceNumber")
+ .HasColumnType("bigint");
+
+ b.Property("LockId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("ReceiveCount")
+ .HasColumnType("integer");
+
+ b.Property("Received")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("bytea");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Delivered");
+
+ b.ToTable("InboxState");
+ });
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
+ {
+ b.Property("SequenceNumber")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber"));
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("ConversationId")
+ .HasColumnType("uuid");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uuid");
+
+ b.Property("DestinationAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EnqueueTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FaultAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Headers")
+ .HasColumnType("text");
+
+ b.Property("InboxConsumerId")
+ .HasColumnType("uuid");
+
+ b.Property("InboxMessageId")
+ .HasColumnType("uuid");
+
+ b.Property("InitiatorId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("OutboxId")
+ .HasColumnType("uuid");
+
+ b.Property("Properties")
+ .HasColumnType("text");
+
+ b.Property("RequestId")
+ .HasColumnType("uuid");
+
+ b.Property("ResponseAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("SentTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SourceAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("SequenceNumber");
+
+ b.HasIndex("EnqueueTime");
+
+ b.HasIndex("ExpirationTime");
+
+ b.HasIndex("OutboxId", "SequenceNumber")
+ .IsUnique();
+
+ b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
+ .IsUnique();
+
+ b.ToTable("OutboxMessage");
+ });
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
+ {
+ b.Property("OutboxId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Delivered")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSequenceNumber")
+ .HasColumnType("bigint");
+
+ b.Property("LockId")
+ .HasColumnType("uuid");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("bytea");
+
+ b.HasKey("OutboxId");
+
+ b.HasIndex("Created");
+
+ b.ToTable("OutboxState");
+ });
+
+ modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricAlertRule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasComment("实体唯一标识。");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionJson")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasComment("触发条件 JSON。");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("创建时间(UTC)。");
+
+ b.Property("CreatedBy")
+ .HasColumnType("bigint")
+ .HasComment("创建人用户标识,匿名或系统操作时为 null。");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("软删除时间(UTC),未删除时为 null。");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasComment("删除人用户标识(软删除),未删除时为 null。");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean")
+ .HasComment("是否启用。");
+
+ b.Property("MetricDefinitionId")
+ .HasColumnType("bigint")
+ .HasComment("关联指标。");
+
+ b.Property("NotificationChannels")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasComment("通知渠道。");
+
+ b.Property("Severity")
+ .HasColumnType("integer")
+ .HasComment("告警级别。");
+
+ b.Property("TenantId")
+ .HasColumnType("bigint")
+ .HasComment("所属租户 ID。");
+
+ b.Property