From 8d170ba3f9a9dc68d455080bcf1007e46a3abf11 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"); + } +}