feat(finance): implement invoice and business report backend modules

This commit is contained in:
2026-03-04 16:57:06 +08:00
parent fa6e376b86
commit 5dfaac01fd
69 changed files with 17768 additions and 1 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>
{
}

View File

@@ -0,0 +1,218 @@
namespace TakeoutSaaS.Application.App.Finance.Reports.Dto;
/// <summary>
/// 经营报表列表行 DTO。
/// </summary>
public sealed class FinanceBusinessReportListItemDto
{
/// <summary>
/// 报表 ID。
/// </summary>
public string ReportId { get; set; } = string.Empty;
/// <summary>
/// 日期文案。
/// </summary>
public string DateText { get; set; } = string.Empty;
/// <summary>
/// 营业额。
/// </summary>
public decimal RevenueAmount { get; set; }
/// <summary>
/// 订单数。
/// </summary>
public int OrderCount { get; set; }
/// <summary>
/// 客单价。
/// </summary>
public decimal AverageOrderValue { get; set; }
/// <summary>
/// 退款率(百分数)。
/// </summary>
public decimal RefundRatePercent { get; set; }
/// <summary>
/// 成本总额。
/// </summary>
public decimal CostTotalAmount { get; set; }
/// <summary>
/// 净利润。
/// </summary>
public decimal NetProfitAmount { get; set; }
/// <summary>
/// 利润率(百分数)。
/// </summary>
public decimal ProfitRatePercent { get; set; }
/// <summary>
/// 状态编码。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 状态文案。
/// </summary>
public string StatusText { get; set; } = string.Empty;
/// <summary>
/// 是否可下载。
/// </summary>
public bool CanDownload { get; set; }
}
/// <summary>
/// 经营报表列表结果 DTO。
/// </summary>
public sealed class FinanceBusinessReportListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public List<FinanceBusinessReportListItemDto> Items { get; set; } = [];
/// <summary>
/// 总数。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; }
}
/// <summary>
/// 经营报表 KPI DTO。
/// </summary>
public sealed class FinanceBusinessReportKpiDto
{
/// <summary>
/// 指标键。
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// 指标名称。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 指标值文本。
/// </summary>
public string ValueText { get; set; } = string.Empty;
/// <summary>
/// 同比变化率(百分数)。
/// </summary>
public decimal YoyChangeRate { get; set; }
/// <summary>
/// 环比变化率(百分数)。
/// </summary>
public decimal MomChangeRate { get; set; }
}
/// <summary>
/// 经营报表明细行 DTO。
/// </summary>
public sealed class FinanceBusinessReportBreakdownItemDto
{
/// <summary>
/// 明细键。
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// 明细名称。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 占比(百分数)。
/// </summary>
public decimal RatioPercent { get; set; }
}
/// <summary>
/// 经营报表详情 DTO。
/// </summary>
public sealed class FinanceBusinessReportDetailDto
{
/// <summary>
/// 报表 ID。
/// </summary>
public string ReportId { get; set; } = string.Empty;
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 周期类型编码。
/// </summary>
public string PeriodType { get; set; } = string.Empty;
/// <summary>
/// 状态编码。
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// 状态文案。
/// </summary>
public string StatusText { get; set; } = string.Empty;
/// <summary>
/// 关键指标。
/// </summary>
public List<FinanceBusinessReportKpiDto> Kpis { get; set; } = [];
/// <summary>
/// 收入明细(按渠道)。
/// </summary>
public List<FinanceBusinessReportBreakdownItemDto> IncomeBreakdowns { get; set; } = [];
/// <summary>
/// 成本明细(按类别)。
/// </summary>
public List<FinanceBusinessReportBreakdownItemDto> CostBreakdowns { get; set; } = [];
}
/// <summary>
/// 经营报表导出 DTO。
/// </summary>
public sealed class FinanceBusinessReportExportDto
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// Base64 文件内容。
/// </summary>
public string FileContentBase64 { get; set; } = string.Empty;
/// <summary>
/// 总记录数。
/// </summary>
public int TotalCount { get; set; }
}

View File

@@ -0,0 +1,112 @@
using System.Globalization;
using System.IO.Compression;
using MediatR;
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Domain.Finance.Services;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
/// <summary>
/// 经营报表批量导出处理器ZIPPDF + Excel
/// </summary>
public sealed class ExportFinanceBusinessReportBatchQueryHandler(
IFinanceBusinessReportRepository financeBusinessReportRepository,
IFinanceBusinessReportExportService financeBusinessReportExportService,
ITenantProvider tenantProvider)
: IRequestHandler<ExportFinanceBusinessReportBatchQuery, FinanceBusinessReportExportDto>
{
/// <inheritdoc />
public async Task<FinanceBusinessReportExportDto> Handle(
ExportFinanceBusinessReportBatchQuery request,
CancellationToken cancellationToken)
{
// 1. 读取租户上下文并归一化分页参数。
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedPage = Math.Max(1, request.Page);
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
// 2. 确保成本配置并补齐快照。
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
tenantId,
request.StoreId,
cancellationToken);
await financeBusinessReportRepository.QueueSnapshotsForPageAsync(
tenantId,
request.StoreId,
request.PeriodType,
normalizedPage,
normalizedPageSize,
cancellationToken);
// 3. 查询导出明细集合(允许实时补算)。
var details = await financeBusinessReportRepository.ListBatchDetailsAsync(
tenantId,
request.StoreId,
request.PeriodType,
normalizedPage,
normalizedPageSize,
allowRealtimeBuild: true,
cancellationToken);
// 4. 生成批量 PDF/Excel 并打包 ZIP。
var periodCode = FinanceBusinessReportMapping.ToPeriodTypeCode(request.PeriodType);
var zipBytes = await CreateZipAsync(
details,
periodCode,
financeBusinessReportExportService,
cancellationToken);
return new FinanceBusinessReportExportDto
{
FileName = string.Create(
CultureInfo.InvariantCulture,
$"business-report-batch-{request.StoreId}-{periodCode}-{DateTime.UtcNow:yyyyMMddHHmmss}.zip"),
FileContentBase64 = Convert.ToBase64String(zipBytes),
TotalCount = details.Count
};
}
private static async Task<byte[]> CreateZipAsync(
IReadOnlyList<Domain.Finance.Models.FinanceBusinessReportDetailSnapshot> details,
string periodCode,
IFinanceBusinessReportExportService exportService,
CancellationToken cancellationToken)
{
if (details.Count == 0)
{
using var emptyStream = new MemoryStream();
using (var emptyArchive = new ZipArchive(emptyStream, ZipArchiveMode.Create, true))
{
var entry = emptyArchive.CreateEntry("README.txt");
await using var writer = new StreamWriter(entry.Open());
await writer.WriteAsync("No business report data in current selection.");
}
return emptyStream.ToArray();
}
var pdfBytes = await exportService.ExportBatchPdfAsync(details, cancellationToken);
var excelBytes = await exportService.ExportBatchExcelAsync(details, cancellationToken);
using var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true))
{
var pdfEntry = archive.CreateEntry($"business-report-{periodCode}.pdf");
await using (var pdfEntryStream = pdfEntry.Open())
{
await pdfEntryStream.WriteAsync(pdfBytes, cancellationToken);
}
var excelEntry = archive.CreateEntry($"business-report-{periodCode}.xlsx");
await using (var excelEntryStream = excelEntry.Open())
{
await excelEntryStream.WriteAsync(excelBytes, cancellationToken);
}
}
return stream.ToArray();
}
}

View File

@@ -0,0 +1,59 @@
using System.Globalization;
using MediatR;
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Domain.Finance.Services;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
/// <summary>
/// 经营报表 Excel 导出处理器。
/// </summary>
public sealed class ExportFinanceBusinessReportExcelQueryHandler(
IFinanceBusinessReportRepository financeBusinessReportRepository,
IFinanceBusinessReportExportService financeBusinessReportExportService,
ITenantProvider tenantProvider)
: IRequestHandler<ExportFinanceBusinessReportExcelQuery, FinanceBusinessReportExportDto>
{
/// <inheritdoc />
public async Task<FinanceBusinessReportExportDto> Handle(
ExportFinanceBusinessReportExcelQuery request,
CancellationToken cancellationToken)
{
// 1. 读取租户上下文并确保成本配置存在。
var tenantId = tenantProvider.GetCurrentTenantId();
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
tenantId,
request.StoreId,
cancellationToken);
// 2. 查询报表详情(允许实时补算)。
var detail = await financeBusinessReportRepository.GetDetailAsync(
tenantId,
request.StoreId,
request.ReportId,
allowRealtimeBuild: true,
cancellationToken);
if (detail is null)
{
throw new BusinessException(ErrorCodes.NotFound, "经营报表不存在");
}
// 3. 导出 Excel 并返回 Base64。
var fileBytes = await financeBusinessReportExportService.ExportSingleExcelAsync(detail, cancellationToken);
var periodCode = FinanceBusinessReportMapping.ToPeriodTypeCode(detail.PeriodType);
return new FinanceBusinessReportExportDto
{
FileName = string.Create(
CultureInfo.InvariantCulture,
$"business-report-{request.StoreId}-{periodCode}-{request.ReportId}.xlsx"),
FileContentBase64 = Convert.ToBase64String(fileBytes),
TotalCount = 1
};
}
}

View File

@@ -0,0 +1,59 @@
using System.Globalization;
using MediatR;
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Domain.Finance.Services;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
/// <summary>
/// 经营报表 PDF 导出处理器。
/// </summary>
public sealed class ExportFinanceBusinessReportPdfQueryHandler(
IFinanceBusinessReportRepository financeBusinessReportRepository,
IFinanceBusinessReportExportService financeBusinessReportExportService,
ITenantProvider tenantProvider)
: IRequestHandler<ExportFinanceBusinessReportPdfQuery, FinanceBusinessReportExportDto>
{
/// <inheritdoc />
public async Task<FinanceBusinessReportExportDto> Handle(
ExportFinanceBusinessReportPdfQuery request,
CancellationToken cancellationToken)
{
// 1. 读取租户上下文并确保成本配置存在。
var tenantId = tenantProvider.GetCurrentTenantId();
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
tenantId,
request.StoreId,
cancellationToken);
// 2. 查询报表详情(允许实时补算)。
var detail = await financeBusinessReportRepository.GetDetailAsync(
tenantId,
request.StoreId,
request.ReportId,
allowRealtimeBuild: true,
cancellationToken);
if (detail is null)
{
throw new BusinessException(ErrorCodes.NotFound, "经营报表不存在");
}
// 3. 导出 PDF 并返回 Base64。
var fileBytes = await financeBusinessReportExportService.ExportSinglePdfAsync(detail, cancellationToken);
var periodCode = FinanceBusinessReportMapping.ToPeriodTypeCode(detail.PeriodType);
return new FinanceBusinessReportExportDto
{
FileName = string.Create(
CultureInfo.InvariantCulture,
$"business-report-{request.StoreId}-{periodCode}-{request.ReportId}.pdf"),
FileContentBase64 = Convert.ToBase64String(fileBytes),
TotalCount = 1
};
}
}

View File

@@ -0,0 +1,178 @@
using System.Globalization;
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
/// <summary>
/// 经营报表映射工具。
/// </summary>
internal static class FinanceBusinessReportMapping
{
/// <summary>
/// 映射列表行 DTO。
/// </summary>
public static FinanceBusinessReportListItemDto ToListItem(FinanceBusinessReportListItemSnapshot source)
{
return new FinanceBusinessReportListItemDto
{
ReportId = source.ReportId.ToString(CultureInfo.InvariantCulture),
DateText = FormatPeriodText(source.PeriodType, source.PeriodStartAt, source.PeriodEndAt),
RevenueAmount = RoundMoney(source.RevenueAmount),
OrderCount = Math.Max(0, source.OrderCount),
AverageOrderValue = RoundMoney(source.AverageOrderValue),
RefundRatePercent = RoundPercent(source.RefundRate),
CostTotalAmount = RoundMoney(source.CostTotalAmount),
NetProfitAmount = RoundMoney(source.NetProfitAmount),
ProfitRatePercent = RoundPercent(source.ProfitRate),
Status = ToStatusCode(source.Status),
StatusText = ToStatusText(source.Status),
CanDownload = source.Status == FinanceBusinessReportStatus.Succeeded
};
}
/// <summary>
/// 映射详情 DTO。
/// </summary>
public static FinanceBusinessReportDetailDto ToDetail(FinanceBusinessReportDetailSnapshot source)
{
return new FinanceBusinessReportDetailDto
{
ReportId = source.ReportId.ToString(CultureInfo.InvariantCulture),
Title = BuildTitle(source.PeriodType, source.PeriodStartAt, source.PeriodEndAt),
PeriodType = ToPeriodTypeCode(source.PeriodType),
Status = ToStatusCode(source.Status),
StatusText = ToStatusText(source.Status),
Kpis = source.Kpis.Select(ToKpi).ToList(),
IncomeBreakdowns = source.IncomeBreakdowns.Select(ToBreakdown).ToList(),
CostBreakdowns = source.CostBreakdowns.Select(ToBreakdown).ToList()
};
}
/// <summary>
/// 周期类型编码。
/// </summary>
public static string ToPeriodTypeCode(FinanceBusinessReportPeriodType value)
{
return value switch
{
FinanceBusinessReportPeriodType.Daily => "daily",
FinanceBusinessReportPeriodType.Weekly => "weekly",
FinanceBusinessReportPeriodType.Monthly => "monthly",
_ => "daily"
};
}
private static FinanceBusinessReportKpiDto ToKpi(FinanceBusinessReportKpiSnapshot source)
{
return new FinanceBusinessReportKpiDto
{
Key = source.Key,
Label = source.Label,
ValueText = FormatKpiValue(source.Key, source.Value),
YoyChangeRate = RoundRate(source.YoyChangeRate),
MomChangeRate = RoundRate(source.MomChangeRate)
};
}
private static FinanceBusinessReportBreakdownItemDto ToBreakdown(FinanceBusinessReportBreakdownSnapshot source)
{
return new FinanceBusinessReportBreakdownItemDto
{
Key = source.Key,
Label = source.Label,
Amount = RoundMoney(source.Amount),
RatioPercent = RoundPercent(source.Ratio)
};
}
private static string FormatPeriodText(
FinanceBusinessReportPeriodType periodType,
DateTime startAt,
DateTime endAt)
{
return periodType switch
{
FinanceBusinessReportPeriodType.Daily => startAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
FinanceBusinessReportPeriodType.Weekly =>
$"{startAt:MM-dd} ~ {endAt.AddDays(-1):MM-dd}",
FinanceBusinessReportPeriodType.Monthly => startAt.ToString("yyyy年M月", CultureInfo.InvariantCulture),
_ => startAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
};
}
private static string BuildTitle(
FinanceBusinessReportPeriodType periodType,
DateTime startAt,
DateTime endAt)
{
return periodType switch
{
FinanceBusinessReportPeriodType.Daily => $"{startAt:yyyy年M月d日} 经营日报",
FinanceBusinessReportPeriodType.Weekly => $"{startAt:yyyy年M月d日}~{endAt.AddDays(-1):M月d日} 经营周报",
FinanceBusinessReportPeriodType.Monthly => $"{startAt:yyyy年M月} 经营月报",
_ => "经营报表"
};
}
private static string ToStatusCode(FinanceBusinessReportStatus status)
{
return status switch
{
FinanceBusinessReportStatus.Queued => "queued",
FinanceBusinessReportStatus.Running => "running",
FinanceBusinessReportStatus.Succeeded => "succeeded",
FinanceBusinessReportStatus.Failed => "failed",
_ => "queued"
};
}
private static string ToStatusText(FinanceBusinessReportStatus status)
{
return status switch
{
FinanceBusinessReportStatus.Queued => "排队中",
FinanceBusinessReportStatus.Running => "生成中",
FinanceBusinessReportStatus.Succeeded => "已生成",
FinanceBusinessReportStatus.Failed => "生成失败",
_ => "排队中"
};
}
private static string FormatKpiValue(string key, decimal value)
{
if (key is "order_count")
{
return Math.Round(value, 0, MidpointRounding.AwayFromZero).ToString("0", CultureInfo.InvariantCulture);
}
if (key is "refund_rate" or "profit_rate")
{
return $"{RoundPercent(value):0.##}%";
}
if (key is "average_order_value")
{
return $"¥{RoundMoney(value):0.##}";
}
return $"¥{RoundMoney(value):0.##}";
}
private static decimal RoundMoney(decimal value)
{
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
private static decimal RoundPercent(decimal value)
{
return decimal.Round(value * 100m, 2, MidpointRounding.AwayFromZero);
}
private static decimal RoundRate(decimal value)
{
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
}

View File

@@ -0,0 +1,39 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
/// <summary>
/// 经营报表详情查询处理器。
/// </summary>
public sealed class GetFinanceBusinessReportDetailQueryHandler(
IFinanceBusinessReportRepository financeBusinessReportRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetFinanceBusinessReportDetailQuery, FinanceBusinessReportDetailDto?>
{
/// <inheritdoc />
public async Task<FinanceBusinessReportDetailDto?> Handle(
GetFinanceBusinessReportDetailQuery request,
CancellationToken cancellationToken)
{
// 1. 读取租户上下文并确保成本配置存在。
var tenantId = tenantProvider.GetCurrentTenantId();
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
tenantId,
request.StoreId,
cancellationToken);
// 2. 查询详情(允许实时补算)并映射输出。
var detail = await financeBusinessReportRepository.GetDetailAsync(
tenantId,
request.StoreId,
request.ReportId,
allowRealtimeBuild: true,
cancellationToken);
return detail is null ? null : FinanceBusinessReportMapping.ToDetail(detail);
}
}

View File

@@ -0,0 +1,57 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Finance.Reports.Handlers;
/// <summary>
/// 经营报表分页查询处理器。
/// </summary>
public sealed class SearchFinanceBusinessReportListQueryHandler(
IFinanceBusinessReportRepository financeBusinessReportRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SearchFinanceBusinessReportListQuery, FinanceBusinessReportListResultDto>
{
/// <inheritdoc />
public async Task<FinanceBusinessReportListResultDto> Handle(
SearchFinanceBusinessReportListQuery request,
CancellationToken cancellationToken)
{
// 1. 读取租户上下文并归一化分页参数。
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedPage = Math.Max(1, request.Page);
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
// 2. 确保成本配置并补齐分页周期快照。
await financeBusinessReportRepository.EnsureDefaultCostProfilesAsync(
tenantId,
request.StoreId,
cancellationToken);
await financeBusinessReportRepository.QueueSnapshotsForPageAsync(
tenantId,
request.StoreId,
request.PeriodType,
normalizedPage,
normalizedPageSize,
cancellationToken);
// 3. 查询分页快照并映射输出。
var pageSnapshot = await financeBusinessReportRepository.SearchPageAsync(
tenantId,
request.StoreId,
request.PeriodType,
normalizedPage,
normalizedPageSize,
cancellationToken);
return new FinanceBusinessReportListResultDto
{
Items = pageSnapshot.Items.Select(FinanceBusinessReportMapping.ToListItem).ToList(),
Total = pageSnapshot.TotalCount,
Page = normalizedPage,
PageSize = normalizedPageSize
};
}
}

View File

@@ -0,0 +1,31 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
using TakeoutSaaS.Domain.Finance.Enums;
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
/// <summary>
/// 批量导出经营报表ZIPPDF + Excel
/// </summary>
public sealed class ExportFinanceBusinessReportBatchQuery : IRequest<FinanceBusinessReportExportDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 周期类型。
/// </summary>
public FinanceBusinessReportPeriodType PeriodType { get; init; } = FinanceBusinessReportPeriodType.Daily;
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
/// <summary>
/// 导出经营报表 Excel。
/// </summary>
public sealed class ExportFinanceBusinessReportExcelQuery : IRequest<FinanceBusinessReportExportDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 报表 ID。
/// </summary>
public long ReportId { get; init; }
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
/// <summary>
/// 导出经营报表 PDF。
/// </summary>
public sealed class ExportFinanceBusinessReportPdfQuery : IRequest<FinanceBusinessReportExportDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 报表 ID。
/// </summary>
public long ReportId { get; init; }
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
/// <summary>
/// 查询经营报表详情。
/// </summary>
public sealed class GetFinanceBusinessReportDetailQuery : IRequest<FinanceBusinessReportDetailDto?>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 报表 ID。
/// </summary>
public long ReportId { get; init; }
}

View File

@@ -0,0 +1,31 @@
using MediatR;
using TakeoutSaaS.Application.App.Finance.Reports.Dto;
using TakeoutSaaS.Domain.Finance.Enums;
namespace TakeoutSaaS.Application.App.Finance.Reports.Queries;
/// <summary>
/// 查询经营报表分页列表。
/// </summary>
public sealed class SearchFinanceBusinessReportListQuery : IRequest<FinanceBusinessReportListResultDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 周期类型。
/// </summary>
public FinanceBusinessReportPeriodType PeriodType { get; init; } = FinanceBusinessReportPeriodType.Daily;
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
/// <summary>
/// 经营报表批量导出查询验证器。
/// </summary>
public sealed class ExportFinanceBusinessReportBatchQueryValidator : AbstractValidator<ExportFinanceBusinessReportBatchQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ExportFinanceBusinessReportBatchQueryValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
}
}

View File

@@ -0,0 +1,19 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
/// <summary>
/// 经营报表 Excel 导出查询验证器。
/// </summary>
public sealed class ExportFinanceBusinessReportExcelQueryValidator : AbstractValidator<ExportFinanceBusinessReportExcelQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ExportFinanceBusinessReportExcelQueryValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.ReportId).GreaterThan(0);
}
}

View File

@@ -0,0 +1,19 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
/// <summary>
/// 经营报表 PDF 导出查询验证器。
/// </summary>
public sealed class ExportFinanceBusinessReportPdfQueryValidator : AbstractValidator<ExportFinanceBusinessReportPdfQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ExportFinanceBusinessReportPdfQueryValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.ReportId).GreaterThan(0);
}
}

View File

@@ -0,0 +1,19 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
/// <summary>
/// 经营报表详情查询验证器。
/// </summary>
public sealed class GetFinanceBusinessReportDetailQueryValidator : AbstractValidator<GetFinanceBusinessReportDetailQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public GetFinanceBusinessReportDetailQueryValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.ReportId).GreaterThan(0);
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Finance.Reports.Queries;
namespace TakeoutSaaS.Application.App.Finance.Reports.Validators;
/// <summary>
/// 经营报表分页查询验证器。
/// </summary>
public sealed class SearchFinanceBusinessReportListQueryValidator : AbstractValidator<SearchFinanceBusinessReportListQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SearchFinanceBusinessReportListQueryValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
}
}