From 54130547c06819f95f2941b202df86e97a1df70a Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Wed, 4 Mar 2026 16:48:58 +0800
Subject: [PATCH] =?UTF-8?q?feat(finance):=20=E5=AE=8C=E6=88=90=E5=8F=91?=
=?UTF-8?q?=E7=A5=A8=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=E5=90=8E=E7=AB=AF?=
=?UTF-8?q?=E5=AE=9E=E7=8E=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Finance/FinanceInvoiceContracts.cs | 533 ++++++++++++++++++
.../Controllers/FinanceInvoiceController.cs | 308 ++++++++++
.../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 +
.../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 | 1 +
.../App/Persistence/TakeoutAppDbContext.cs | 56 ++
.../Repositories/EfTenantInvoiceRepository.cs | 215 +++++++
.../20260305103000_AddFinanceInvoiceModule.cs | 131 +++++
33 files changed, 2970 insertions(+)
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/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/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/EfTenantInvoiceRepository.cs
create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305103000_AddFinanceInvoiceModule.cs
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/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/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..cf47802 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
@@ -69,6 +69,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
index ac4bbc9..3df80ff 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
@@ -95,6 +95,14 @@ public sealed class TakeoutAppDbContext(
///
public DbSet TenantVisibilityRoleRules => Set();
///
+ /// 租户发票设置。
+ ///
+ public DbSet TenantInvoiceSettings => Set();
+ ///
+ /// 租户发票记录。
+ ///
+ public DbSet TenantInvoiceRecords => Set();
+ ///
/// 成本录入汇总。
///
public DbSet FinanceCostEntries => Set();
@@ -534,6 +542,8 @@ public sealed class TakeoutAppDbContext(
ConfigureTenantAnnouncementRead(modelBuilder.Entity());
ConfigureTenantVerificationProfile(modelBuilder.Entity());
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity());
+ ConfigureTenantInvoiceSetting(modelBuilder.Entity());
+ ConfigureTenantInvoiceRecord(modelBuilder.Entity());
ConfigureFinanceCostEntry(modelBuilder.Entity());
ConfigureFinanceCostEntryItem(modelBuilder.Entity());
ConfigureQuotaPackage(modelBuilder.Entity());
@@ -1053,6 +1063,52 @@ 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 ConfigureFinanceCostEntry(EntityTypeBuilder builder)
{
builder.ToTable("finance_cost_entries");
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/Migrations/20260305103000_AddFinanceInvoiceModule.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305103000_AddFinanceInvoiceModule.cs
new file mode 100644
index 0000000..75560ab
--- /dev/null
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260305103000_AddFinanceInvoiceModule.cs
@@ -0,0 +1,131 @@
+using System;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+
+#nullable disable
+
+namespace TakeoutSaaS.Infrastructure.Migrations;
+
+///
+/// 新增财务中心发票管理表结构。
+///
+[DbContext(typeof(TakeoutAppDbContext))]
+[Migration("20260305103000_AddFinanceInvoiceModule")]
+public sealed class AddFinanceInvoiceModule : Migration
+{
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "finance_invoice_records",
+ columns: table => new
+ {
+ Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。")
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ InvoiceNo = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "发票号码。"),
+ ApplicantName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "申请人。"),
+ CompanyName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "开票抬头(公司名)。"),
+ TaxpayerNumber = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "纳税人识别号快照。"),
+ InvoiceType = table.Column(type: "integer", nullable: false, comment: "发票类型。"),
+ Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "开票金额。"),
+ OrderNo = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "关联订单号。"),
+ ContactEmail = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "接收邮箱。"),
+ ContactPhone = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "联系电话。"),
+ ApplyRemark = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "申请备注。"),
+ Status = table.Column(type: "integer", nullable: false, comment: "发票状态。"),
+ AppliedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "申请时间(UTC)。"),
+ IssuedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "开票时间(UTC)。"),
+ IssuedByUserId = table.Column(type: "bigint", nullable: true, comment: "开票人 ID。"),
+ IssueRemark = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "开票备注。"),
+ VoidedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "作废时间(UTC)。"),
+ VoidedByUserId = table.Column(type: "bigint", nullable: true, comment: "作废人 ID。"),
+ VoidReason = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "作废原因。"),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
+ UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
+ DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
+ CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
+ UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
+ DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
+ TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_finance_invoice_records", x => x.Id);
+ },
+ comment: "租户发票记录。");
+
+ migrationBuilder.CreateTable(
+ name: "finance_invoice_settings",
+ columns: table => new
+ {
+ Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。")
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ CompanyName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "企业名称。"),
+ TaxpayerNumber = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "纳税人识别号。"),
+ RegisteredAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "注册地址。"),
+ RegisteredPhone = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "注册电话。"),
+ BankName = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "开户银行。"),
+ BankAccount = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "银行账号。"),
+ EnableElectronicNormalInvoice = table.Column(type: "boolean", nullable: false, comment: "是否启用电子普通发票。"),
+ EnableElectronicSpecialInvoice = table.Column(type: "boolean", nullable: false, comment: "是否启用电子专用发票。"),
+ EnableAutoIssue = table.Column(type: "boolean", nullable: false, comment: "是否启用自动开票。"),
+ AutoIssueMaxAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "自动开票单张最大金额。"),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
+ UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
+ DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
+ CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
+ UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
+ DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
+ TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_finance_invoice_settings", x => x.Id);
+ },
+ comment: "租户发票开票基础设置。");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_finance_invoice_records_TenantId_InvoiceNo",
+ table: "finance_invoice_records",
+ columns: new[] { "TenantId", "InvoiceNo" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_finance_invoice_records_TenantId_InvoiceType_AppliedAt",
+ table: "finance_invoice_records",
+ columns: new[] { "TenantId", "InvoiceType", "AppliedAt" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_finance_invoice_records_TenantId_OrderNo",
+ table: "finance_invoice_records",
+ columns: new[] { "TenantId", "OrderNo" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_finance_invoice_records_TenantId_Status_AppliedAt",
+ table: "finance_invoice_records",
+ columns: new[] { "TenantId", "Status", "AppliedAt" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_finance_invoice_records_TenantId_Status_IssuedAt",
+ table: "finance_invoice_records",
+ columns: new[] { "TenantId", "Status", "IssuedAt" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_finance_invoice_settings_TenantId",
+ table: "finance_invoice_settings",
+ column: "TenantId",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "finance_invoice_records");
+
+ migrationBuilder.DropTable(
+ name: "finance_invoice_settings");
+ }
+}