feat(finance): 完成发票管理模块后端实现

This commit is contained in:
2026-03-04 16:48:58 +08:00
parent 21a689edec
commit c79e9bd6e8
33 changed files with 2970 additions and 0 deletions

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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();
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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
};
}
}

View File

@@ -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;
}
}

View File

@@ -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, "生成发票号码失败,请稍后重试");
}
}

View File

@@ -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);
}
}

View File

@@ -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)
};
}
}

View File

@@ -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);
}
}

View File

@@ -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, "电子专用发票未启用");
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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>
{
}