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

@@ -69,6 +69,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
services.AddScoped<ITenantQuotaUsageHistoryRepository, EfTenantQuotaUsageHistoryRepository>();
services.AddScoped<ITenantVisibilityRoleRuleRepository, TenantVisibilityRoleRuleRepository>();
services.AddScoped<ITenantInvoiceRepository, EfTenantInvoiceRepository>();
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
services.AddScoped<IQuotaPackageRepository, EfQuotaPackageRepository>();
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();

View File

@@ -95,6 +95,14 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<TenantVisibilityRoleRule> TenantVisibilityRoleRules => Set<TenantVisibilityRoleRule>();
/// <summary>
/// 租户发票设置。
/// </summary>
public DbSet<TenantInvoiceSetting> TenantInvoiceSettings => Set<TenantInvoiceSetting>();
/// <summary>
/// 租户发票记录。
/// </summary>
public DbSet<TenantInvoiceRecord> TenantInvoiceRecords => Set<TenantInvoiceRecord>();
/// <summary>
/// 成本录入汇总。
/// </summary>
public DbSet<FinanceCostEntry> FinanceCostEntries => Set<FinanceCostEntry>();
@@ -534,6 +542,8 @@ public sealed class TakeoutAppDbContext(
ConfigureTenantAnnouncementRead(modelBuilder.Entity<TenantAnnouncementRead>());
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity<TenantVisibilityRoleRule>());
ConfigureTenantInvoiceSetting(modelBuilder.Entity<TenantInvoiceSetting>());
ConfigureTenantInvoiceRecord(modelBuilder.Entity<TenantInvoiceRecord>());
ConfigureFinanceCostEntry(modelBuilder.Entity<FinanceCostEntry>());
ConfigureFinanceCostEntryItem(modelBuilder.Entity<FinanceCostEntryItem>());
ConfigureQuotaPackage(modelBuilder.Entity<QuotaPackage>());
@@ -1053,6 +1063,52 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => x.TenantId).IsUnique();
}
private static void ConfigureTenantInvoiceSetting(EntityTypeBuilder<TenantInvoiceSetting> builder)
{
builder.ToTable("finance_invoice_settings");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.CompanyName).HasMaxLength(128).IsRequired();
builder.Property(x => x.TaxpayerNumber).HasMaxLength(64).IsRequired();
builder.Property(x => x.RegisteredAddress).HasMaxLength(256);
builder.Property(x => x.RegisteredPhone).HasMaxLength(32);
builder.Property(x => x.BankName).HasMaxLength(128);
builder.Property(x => x.BankAccount).HasMaxLength(64);
builder.Property(x => x.EnableElectronicNormalInvoice).IsRequired();
builder.Property(x => x.EnableElectronicSpecialInvoice).IsRequired();
builder.Property(x => x.EnableAutoIssue).IsRequired();
builder.Property(x => x.AutoIssueMaxAmount).HasPrecision(18, 2).IsRequired();
builder.HasIndex(x => x.TenantId).IsUnique();
}
private static void ConfigureTenantInvoiceRecord(EntityTypeBuilder<TenantInvoiceRecord> builder)
{
builder.ToTable("finance_invoice_records");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.InvoiceNo).HasMaxLength(32).IsRequired();
builder.Property(x => x.ApplicantName).HasMaxLength(64).IsRequired();
builder.Property(x => x.CompanyName).HasMaxLength(128).IsRequired();
builder.Property(x => x.TaxpayerNumber).HasMaxLength(64);
builder.Property(x => x.InvoiceType).HasConversion<int>().IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
builder.Property(x => x.OrderNo).HasMaxLength(32).IsRequired();
builder.Property(x => x.ContactEmail).HasMaxLength(128);
builder.Property(x => x.ContactPhone).HasMaxLength(32);
builder.Property(x => x.ApplyRemark).HasMaxLength(256);
builder.Property(x => x.Status).HasConversion<int>().IsRequired();
builder.Property(x => x.AppliedAt).IsRequired();
builder.Property(x => x.IssueRemark).HasMaxLength(256);
builder.Property(x => x.VoidReason).HasMaxLength(256);
builder.HasIndex(x => new { x.TenantId, x.InvoiceNo }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.OrderNo });
builder.HasIndex(x => new { x.TenantId, x.Status, x.AppliedAt });
builder.HasIndex(x => new { x.TenantId, x.Status, x.IssuedAt });
builder.HasIndex(x => new { x.TenantId, x.InvoiceType, x.AppliedAt });
}
private static void ConfigureFinanceCostEntry(EntityTypeBuilder<FinanceCostEntry> builder)
{
builder.ToTable("finance_cost_entries");

View File

@@ -0,0 +1,215 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 租户发票仓储 EF Core 实现。
/// </summary>
public sealed class EfTenantInvoiceRepository(TakeoutAppDbContext context) : ITenantInvoiceRepository
{
/// <inheritdoc />
public Task<TenantInvoiceSetting?> GetSettingAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.TenantInvoiceSettings
.Where(item => item.TenantId == tenantId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default)
{
return context.TenantInvoiceSettings.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateSettingAsync(TenantInvoiceSetting entity, CancellationToken cancellationToken = default)
{
context.TenantInvoiceSettings.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantInvoiceRecord> Items, int TotalCount)> SearchRecordsAsync(
long tenantId,
DateTime? startUtc,
DateTime? endUtc,
TenantInvoiceStatus? status,
TenantInvoiceType? invoiceType,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 500);
var query = BuildRecordQuery(tenantId, startUtc, endUtc, status, invoiceType, keyword);
var totalCount = await query.CountAsync(cancellationToken);
if (totalCount == 0)
{
return ([], 0);
}
var items = await query
.OrderByDescending(item => item.AppliedAt)
.ThenByDescending(item => item.Id)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, totalCount);
}
/// <inheritdoc />
public async Task<TenantInvoiceRecordStatsSnapshot> GetStatsAsync(
long tenantId,
DateTime nowUtc,
CancellationToken cancellationToken = default)
{
var utcNow = NormalizeUtc(nowUtc);
var monthStart = new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var summary = await context.TenantInvoiceRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId)
.GroupBy(_ => 1)
.Select(group => new
{
CurrentMonthIssuedAmount = group
.Where(item =>
item.Status == TenantInvoiceStatus.Issued &&
item.IssuedAt.HasValue &&
item.IssuedAt.Value >= monthStart &&
item.IssuedAt.Value <= utcNow)
.Sum(item => item.Amount),
CurrentMonthIssuedCount = group
.Count(item =>
item.Status == TenantInvoiceStatus.Issued &&
item.IssuedAt.HasValue &&
item.IssuedAt.Value >= monthStart &&
item.IssuedAt.Value <= utcNow),
PendingCount = group.Count(item => item.Status == TenantInvoiceStatus.Pending),
VoidedCount = group.Count(item => item.Status == TenantInvoiceStatus.Voided)
})
.FirstOrDefaultAsync(cancellationToken);
if (summary is null)
{
return new TenantInvoiceRecordStatsSnapshot();
}
return new TenantInvoiceRecordStatsSnapshot
{
CurrentMonthIssuedAmount = summary.CurrentMonthIssuedAmount,
CurrentMonthIssuedCount = summary.CurrentMonthIssuedCount,
PendingCount = summary.PendingCount,
VoidedCount = summary.VoidedCount
};
}
/// <inheritdoc />
public Task<TenantInvoiceRecord?> FindRecordByIdAsync(
long tenantId,
long recordId,
CancellationToken cancellationToken = default)
{
return context.TenantInvoiceRecords
.Where(item => item.TenantId == tenantId && item.Id == recordId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsInvoiceNoAsync(
long tenantId,
string invoiceNo,
CancellationToken cancellationToken = default)
{
return context.TenantInvoiceRecords
.AsNoTracking()
.AnyAsync(
item => item.TenantId == tenantId && item.InvoiceNo == invoiceNo,
cancellationToken);
}
/// <inheritdoc />
public Task AddRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default)
{
return context.TenantInvoiceRecords.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateRecordAsync(TenantInvoiceRecord entity, CancellationToken cancellationToken = default)
{
context.TenantInvoiceRecords.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
private IQueryable<TenantInvoiceRecord> BuildRecordQuery(
long tenantId,
DateTime? startUtc,
DateTime? endUtc,
TenantInvoiceStatus? status,
TenantInvoiceType? invoiceType,
string? keyword)
{
var query = context.TenantInvoiceRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId);
if (startUtc.HasValue)
{
var normalizedStart = NormalizeUtc(startUtc.Value);
query = query.Where(item => item.AppliedAt >= normalizedStart);
}
if (endUtc.HasValue)
{
var normalizedEnd = NormalizeUtc(endUtc.Value);
query = query.Where(item => item.AppliedAt <= normalizedEnd);
}
if (status.HasValue)
{
query = query.Where(item => item.Status == status.Value);
}
if (invoiceType.HasValue)
{
query = query.Where(item => item.InvoiceType == invoiceType.Value);
}
var normalizedKeyword = (keyword ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
var like = $"%{normalizedKeyword}%";
query = query.Where(item =>
EF.Functions.ILike(item.InvoiceNo, like) ||
EF.Functions.ILike(item.CompanyName, like) ||
EF.Functions.ILike(item.ApplicantName, like) ||
EF.Functions.ILike(item.OrderNo, like));
}
return query;
}
private static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
}

View File

@@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TakeoutSaaS.Infrastructure.App.Persistence;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations;
/// <summary>
/// 新增财务中心发票管理表结构。
/// </summary>
[DbContext(typeof(TakeoutAppDbContext))]
[Migration("20260305103000_AddFinanceInvoiceModule")]
public sealed class AddFinanceInvoiceModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "finance_invoice_records",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
InvoiceNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "发票号码。"),
ApplicantName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "申请人。"),
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "开票抬头(公司名)。"),
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "纳税人识别号快照。"),
InvoiceType = table.Column<int>(type: "integer", nullable: false, comment: "发票类型。"),
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "开票金额。"),
OrderNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "关联订单号。"),
ContactEmail = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "接收邮箱。"),
ContactPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "联系电话。"),
ApplyRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "申请备注。"),
Status = table.Column<int>(type: "integer", nullable: false, comment: "发票状态。"),
AppliedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "申请时间UTC。"),
IssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "开票时间UTC。"),
IssuedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "开票人 ID。"),
IssueRemark = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "开票备注。"),
VoidedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "作废时间UTC。"),
VoidedByUserId = table.Column<long>(type: "bigint", nullable: true, comment: "作废人 ID。"),
VoidReason = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "作废原因。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_invoice_records", x => x.Id);
},
comment: "租户发票记录。");
migrationBuilder.CreateTable(
name: "finance_invoice_settings",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CompanyName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "企业名称。"),
TaxpayerNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "纳税人识别号。"),
RegisteredAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "注册地址。"),
RegisteredPhone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true, comment: "注册电话。"),
BankName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "开户银行。"),
BankAccount = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "银行账号。"),
EnableElectronicNormalInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子普通发票。"),
EnableElectronicSpecialInvoice = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用电子专用发票。"),
EnableAutoIssue = table.Column<bool>(type: "boolean", nullable: false, comment: "是否启用自动开票。"),
AutoIssueMaxAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "自动开票单张最大金额。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_finance_invoice_settings", x => x.Id);
},
comment: "租户发票开票基础设置。");
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_InvoiceNo",
table: "finance_invoice_records",
columns: new[] { "TenantId", "InvoiceNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_InvoiceType_AppliedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "InvoiceType", "AppliedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_OrderNo",
table: "finance_invoice_records",
columns: new[] { "TenantId", "OrderNo" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_Status_AppliedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "Status", "AppliedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_records_TenantId_Status_IssuedAt",
table: "finance_invoice_records",
columns: new[] { "TenantId", "Status", "IssuedAt" });
migrationBuilder.CreateIndex(
name: "IX_finance_invoice_settings_TenantId",
table: "finance_invoice_settings",
column: "TenantId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "finance_invoice_records");
migrationBuilder.DropTable(
name: "finance_invoice_settings");
}
}