feat(finance): 完成发票管理模块后端实现
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 申请发票记录命令。
|
||||
/// </summary>
|
||||
public sealed class ApplyFinanceInvoiceRecordCommand : IRequest<FinanceInvoiceRecordDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 申请人。
|
||||
/// </summary>
|
||||
public string ApplicantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票抬头(公司名)。
|
||||
/// </summary>
|
||||
public string CompanyName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纳税人识别号。
|
||||
/// </summary>
|
||||
public string? TaxpayerNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型(normal/special)。
|
||||
/// </summary>
|
||||
public string InvoiceType { get; init; } = "normal";
|
||||
|
||||
/// <summary>
|
||||
/// 开票金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收邮箱。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string? ContactPhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 申请备注。
|
||||
/// </summary>
|
||||
public string? ApplyRemark { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 申请时间(可空,默认当前 UTC)。
|
||||
/// </summary>
|
||||
public DateTime? AppliedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 开票命令。
|
||||
/// </summary>
|
||||
public sealed class IssueFinanceInvoiceRecordCommand : IRequest<FinanceInvoiceIssueResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 发票记录 ID。
|
||||
/// </summary>
|
||||
public long RecordId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 接收邮箱(可选,传入会覆盖原值)。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票备注。
|
||||
/// </summary>
|
||||
public string? IssueRemark { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存发票设置命令。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceInvoiceSettingCommand : IRequest<FinanceInvoiceSettingDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 企业名称。
|
||||
/// </summary>
|
||||
public string CompanyName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纳税人识别号。
|
||||
/// </summary>
|
||||
public string TaxpayerNumber { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册电话。
|
||||
/// </summary>
|
||||
public string? RegisteredPhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开户银行。
|
||||
/// </summary>
|
||||
public string? BankName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 银行账号。
|
||||
/// </summary>
|
||||
public string? BankAccount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用电子普通发票。
|
||||
/// </summary>
|
||||
public bool EnableElectronicNormalInvoice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用电子专用发票。
|
||||
/// </summary>
|
||||
public bool EnableElectronicSpecialInvoice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用自动开票。
|
||||
/// </summary>
|
||||
public bool EnableAutoIssue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 自动开票单张最大金额。
|
||||
/// </summary>
|
||||
public decimal AutoIssueMaxAmount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Invoice.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 作废发票命令。
|
||||
/// </summary>
|
||||
public sealed class VoidFinanceInvoiceRecordCommand : IRequest<FinanceInvoiceRecordDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 发票记录 ID。
|
||||
/// </summary>
|
||||
public long RecordId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 作废原因。
|
||||
/// </summary>
|
||||
public string VoidReason { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 发票开票结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceIssueResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public long RecordId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发票号码。
|
||||
/// </summary>
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票抬头。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 接收邮箱。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime IssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public long RecordId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发票号码。
|
||||
/// </summary>
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 申请人。
|
||||
/// </summary>
|
||||
public string ApplicantName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票抬头(公司名)。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纳税人识别号。
|
||||
/// </summary>
|
||||
public string? TaxpayerNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型编码。
|
||||
/// </summary>
|
||||
public string InvoiceType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型文案。
|
||||
/// </summary>
|
||||
public string InvoiceTypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收邮箱。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 申请备注。
|
||||
/// </summary>
|
||||
public string? ApplyRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 申请时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime AppliedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? IssuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票人 ID。
|
||||
/// </summary>
|
||||
public long? IssuedByUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开票备注。
|
||||
/// </summary>
|
||||
public string? IssueRemark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 作废时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? VoidedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 作废人 ID。
|
||||
/// </summary>
|
||||
public long? VoidedByUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 作废原因。
|
||||
/// </summary>
|
||||
public string? VoidReason { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录列表项 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public long RecordId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发票号码。
|
||||
/// </summary>
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 申请人。
|
||||
/// </summary>
|
||||
public string ApplicantName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 开票抬头(公司名)。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型编码。
|
||||
/// </summary>
|
||||
public string InvoiceType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型文案。
|
||||
/// </summary>
|
||||
public string InvoiceTypeText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联订单号。
|
||||
/// </summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态编码。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态文案。
|
||||
/// </summary>
|
||||
public string StatusText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 申请时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime AppliedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录分页结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceRecordListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<FinanceInvoiceRecordDto> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计。
|
||||
/// </summary>
|
||||
public FinanceInvoiceStatsDto Stats { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 发票设置 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceSettingDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 企业名称。
|
||||
/// </summary>
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 纳税人识别号。
|
||||
/// </summary>
|
||||
public string TaxpayerNumber { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册电话。
|
||||
/// </summary>
|
||||
public string? RegisteredPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开户银行。
|
||||
/// </summary>
|
||||
public string? BankName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 银行账号。
|
||||
/// </summary>
|
||||
public string? BankAccount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用电子普通发票。
|
||||
/// </summary>
|
||||
public bool EnableElectronicNormalInvoice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用电子专用发票。
|
||||
/// </summary>
|
||||
public bool EnableElectronicSpecialInvoice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用自动开票。
|
||||
/// </summary>
|
||||
public bool EnableAutoIssue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 自动开票单张最大金额。
|
||||
/// </summary>
|
||||
public decimal AutoIssueMaxAmount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 发票统计 DTO。
|
||||
/// </summary>
|
||||
public sealed class FinanceInvoiceStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月已开票金额。
|
||||
/// </summary>
|
||||
public decimal CurrentMonthIssuedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月已开票张数。
|
||||
/// </summary>
|
||||
public int CurrentMonthIssuedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待开票数量。
|
||||
/// </summary>
|
||||
public int PendingCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已作废数量。
|
||||
/// </summary>
|
||||
public int VoidedCount { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 发票模块 DTO 构造器。
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 发票模块映射与参数标准化。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 申请发票处理器。
|
||||
/// </summary>
|
||||
public sealed class ApplyFinanceInvoiceRecordCommandHandler(
|
||||
ITenantInvoiceRepository repository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<ApplyFinanceInvoiceRecordCommand, FinanceInvoiceRecordDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceInvoiceRecordDetailDto> 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<string> 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, "生成发票号码失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceInvoiceRecordDetailQueryHandler(
|
||||
ITenantInvoiceRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFinanceInvoiceRecordDetailQuery, FinanceInvoiceRecordDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceInvoiceRecordDetailDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 发票记录分页查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceInvoiceRecordListQueryHandler(
|
||||
ITenantInvoiceRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFinanceInvoiceRecordListQuery, FinanceInvoiceRecordListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceInvoiceRecordListResultDto> 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 发票设置详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceInvoiceSettingDetailQueryHandler(
|
||||
ITenantInvoiceRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFinanceInvoiceSettingDetailQuery, FinanceInvoiceSettingDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceInvoiceSettingDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 发票开票处理器。
|
||||
/// </summary>
|
||||
public sealed class IssueFinanceInvoiceRecordCommandHandler(
|
||||
ITenantInvoiceRepository repository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<IssueFinanceInvoiceRecordCommand, FinanceInvoiceIssueResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceInvoiceIssueResultDto> 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, "电子专用发票未启用");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 保存发票设置处理器。
|
||||
/// </summary>
|
||||
public sealed class SaveFinanceInvoiceSettingCommandHandler(
|
||||
ITenantInvoiceRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SaveFinanceInvoiceSettingCommand, FinanceInvoiceSettingDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceInvoiceSettingDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 发票作废处理器。
|
||||
/// </summary>
|
||||
public sealed class VoidFinanceInvoiceRecordCommandHandler(
|
||||
ITenantInvoiceRepository repository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<VoidFinanceInvoiceRecordCommand, FinanceInvoiceRecordDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FinanceInvoiceRecordDetailDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Invoice.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询发票记录详情。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceInvoiceRecordDetailQuery : IRequest<FinanceInvoiceRecordDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 发票记录 ID。
|
||||
/// </summary>
|
||||
public long RecordId { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 查询发票记录分页。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceInvoiceRecordListQuery : IRequest<FinanceInvoiceRecordListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 开始日期(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? StartDateUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? EndDateUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选。
|
||||
/// </summary>
|
||||
public TenantInvoiceStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型筛选。
|
||||
/// </summary>
|
||||
public TenantInvoiceType? InvoiceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键词。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Finance.Invoice.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Finance.Invoice.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询发票设置详情。
|
||||
/// </summary>
|
||||
public sealed class GetFinanceInvoiceSettingDetailQuery : IRequest<FinanceInvoiceSettingDto>
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user