feat: implement marketing punch card backend module

This commit is contained in:
2026-03-02 21:43:09 +08:00
parent 6588c85f27
commit 3b3bdcee71
48 changed files with 14863 additions and 1 deletions

View File

@@ -48,6 +48,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<ICouponRepository, EfCouponRepository>();
services.AddScoped<INewCustomerGiftRepository, EfNewCustomerGiftRepository>();
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
services.AddScoped<IPunchCardRepository, EfPunchCardRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();

View File

@@ -370,6 +370,18 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<NewCustomerGrowthRecord> NewCustomerGrowthRecords => Set<NewCustomerGrowthRecord>();
/// <summary>
/// 次卡模板。
/// </summary>
public DbSet<PunchCardTemplate> PunchCardTemplates => Set<PunchCardTemplate>();
/// <summary>
/// 次卡实例。
/// </summary>
public DbSet<PunchCardInstance> PunchCardInstances => Set<PunchCardInstance>();
/// <summary>
/// 次卡使用记录。
/// </summary>
public DbSet<PunchCardUsageRecord> PunchCardUsageRecords => Set<PunchCardUsageRecord>();
/// <summary>
/// 会员档案。
/// </summary>
public DbSet<MemberProfile> MemberProfiles => Set<MemberProfile>();
@@ -540,6 +552,9 @@ public sealed class TakeoutAppDbContext(
ConfigureNewCustomerCouponRule(modelBuilder.Entity<NewCustomerCouponRule>());
ConfigureNewCustomerInviteRecord(modelBuilder.Entity<NewCustomerInviteRecord>());
ConfigureNewCustomerGrowthRecord(modelBuilder.Entity<NewCustomerGrowthRecord>());
ConfigurePunchCardTemplate(modelBuilder.Entity<PunchCardTemplate>());
ConfigurePunchCardInstance(modelBuilder.Entity<PunchCardInstance>());
ConfigurePunchCardUsageRecord(modelBuilder.Entity<PunchCardUsageRecord>());
ConfigureMemberProfile(modelBuilder.Entity<MemberProfile>());
ConfigureMemberTier(modelBuilder.Entity<MemberTier>());
ConfigureMemberPointLedger(modelBuilder.Entity<MemberPointLedger>());
@@ -1692,6 +1707,77 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RegisteredAt });
}
private static void ConfigurePunchCardTemplate(EntityTypeBuilder<PunchCardTemplate> builder)
{
builder.ToTable("punch_card_templates");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.CoverImageUrl).HasMaxLength(512);
builder.Property(x => x.SalePrice).HasPrecision(18, 2);
builder.Property(x => x.OriginalPrice).HasPrecision(18, 2);
builder.Property(x => x.TotalTimes).IsRequired();
builder.Property(x => x.ValidityType).HasConversion<int>();
builder.Property(x => x.ValidityDays);
builder.Property(x => x.ValidFrom);
builder.Property(x => x.ValidTo);
builder.Property(x => x.ScopeType).HasConversion<int>();
builder.Property(x => x.ScopeCategoryIdsJson).HasColumnType("text").IsRequired();
builder.Property(x => x.ScopeTagIdsJson).HasColumnType("text").IsRequired();
builder.Property(x => x.ScopeProductIdsJson).HasColumnType("text").IsRequired();
builder.Property(x => x.UsageMode).HasConversion<int>();
builder.Property(x => x.UsageCapAmount).HasPrecision(18, 2);
builder.Property(x => x.DailyLimit);
builder.Property(x => x.PerOrderLimit);
builder.Property(x => x.PerUserPurchaseLimit);
builder.Property(x => x.AllowTransfer).IsRequired();
builder.Property(x => x.ExpireStrategy).HasConversion<int>();
builder.Property(x => x.Description).HasMaxLength(512);
builder.Property(x => x.NotifyChannelsJson).HasColumnType("text").IsRequired();
builder.Property(x => x.Status).HasConversion<int>();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status });
}
private static void ConfigurePunchCardInstance(EntityTypeBuilder<PunchCardInstance> builder)
{
builder.ToTable("punch_card_instances");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.PunchCardTemplateId).IsRequired();
builder.Property(x => x.InstanceNo).HasMaxLength(32).IsRequired();
builder.Property(x => x.MemberName).HasMaxLength(64).IsRequired();
builder.Property(x => x.MemberPhoneMasked).HasMaxLength(32).IsRequired();
builder.Property(x => x.PurchasedAt).IsRequired();
builder.Property(x => x.ExpiresAt);
builder.Property(x => x.TotalTimes).IsRequired();
builder.Property(x => x.RemainingTimes).IsRequired();
builder.Property(x => x.PaidAmount).HasPrecision(18, 2);
builder.Property(x => x.Status).HasConversion<int>();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.InstanceNo }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PunchCardTemplateId });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status, x.ExpiresAt });
}
private static void ConfigurePunchCardUsageRecord(EntityTypeBuilder<PunchCardUsageRecord> builder)
{
builder.ToTable("punch_card_usage_records");
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.PunchCardTemplateId).IsRequired();
builder.Property(x => x.PunchCardInstanceId).IsRequired();
builder.Property(x => x.RecordNo).HasMaxLength(32).IsRequired();
builder.Property(x => x.ProductName).HasMaxLength(128).IsRequired();
builder.Property(x => x.UsedAt).IsRequired();
builder.Property(x => x.UsedTimes).IsRequired();
builder.Property(x => x.RemainingTimesAfterUse).IsRequired();
builder.Property(x => x.StatusAfterUse).HasConversion<int>();
builder.Property(x => x.ExtraPayAmount).HasPrecision(18, 2);
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.RecordNo }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PunchCardTemplateId, x.UsedAt });
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.PunchCardInstanceId, x.UsedAt });
}
private static void ConfigureMemberProfile(EntityTypeBuilder<MemberProfile> builder)
{
builder.ToTable("member_profiles");

View File

@@ -0,0 +1,469 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.Coupons.Enums;
using TakeoutSaaS.Domain.Coupons.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 次卡仓储 EF Core 实现。
/// </summary>
public sealed class EfPunchCardRepository(TakeoutAppDbContext context) : IPunchCardRepository
{
/// <inheritdoc />
public async Task<(IReadOnlyList<PunchCardTemplate> Items, int TotalCount)> SearchTemplatesAsync(
long tenantId,
long storeId,
string? keyword,
PunchCardStatus? status,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
var query = context.PunchCardTemplates
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
if (status.HasValue)
{
query = query.Where(item => item.Status == status.Value);
}
var normalizedKeyword = (keyword ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
var keywordLike = $"%{normalizedKeyword}%";
query = query.Where(item => EF.Functions.ILike(item.Name, keywordLike));
}
var totalCount = await query.CountAsync(cancellationToken);
if (totalCount == 0)
{
return ([], 0);
}
var items = await query
.OrderByDescending(item => item.UpdatedAt ?? item.CreatedAt)
.ThenByDescending(item => item.Id)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, totalCount);
}
/// <inheritdoc />
public Task<PunchCardTemplate?> FindTemplateByIdAsync(
long tenantId,
long storeId,
long templateId,
CancellationToken cancellationToken = default)
{
return context.PunchCardTemplates
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Id == templateId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PunchCardTemplate>> GetTemplatesByIdsAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> templateIds,
CancellationToken cancellationToken = default)
{
if (templateIds.Count == 0)
{
return [];
}
return await context.PunchCardTemplates
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
templateIds.Contains(item.Id))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Dictionary<long, PunchCardTemplateAggregateSnapshot>> GetTemplateAggregateByTemplateIdsAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> templateIds,
CancellationToken cancellationToken = default)
{
if (templateIds.Count == 0)
{
return [];
}
var nowUtc = DateTime.UtcNow;
var aggregates = await context.PunchCardInstances
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
templateIds.Contains(item.PunchCardTemplateId))
.GroupBy(item => item.PunchCardTemplateId)
.Select(group => new PunchCardTemplateAggregateSnapshot
{
TemplateId = group.Key,
SoldCount = group.Count(),
ActiveCount = group.Count(item =>
item.Status == PunchCardInstanceStatus.Active &&
item.RemainingTimes > 0 &&
(!item.ExpiresAt.HasValue || item.ExpiresAt.Value >= nowUtc)),
RevenueAmount = group.Sum(item => item.PaidAmount)
})
.ToListAsync(cancellationToken);
return aggregates.ToDictionary(item => item.TemplateId, item => item);
}
/// <inheritdoc />
public async Task<PunchCardTemplateStatsSnapshot> GetTemplateStatsAsync(
long tenantId,
long storeId,
CancellationToken cancellationToken = default)
{
var onSaleCount = await context.PunchCardTemplates
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Status == PunchCardStatus.Enabled)
.CountAsync(cancellationToken);
var summary = await context.PunchCardInstances
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId)
.GroupBy(_ => 1)
.Select(group => new
{
TotalSoldCount = group.Count(),
TotalRevenueAmount = group.Sum(item => item.PaidAmount)
})
.FirstOrDefaultAsync(cancellationToken);
var nowUtc = DateTime.UtcNow;
var activeInUseCount = await context.PunchCardInstances
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Status == PunchCardInstanceStatus.Active &&
item.RemainingTimes > 0 &&
(!item.ExpiresAt.HasValue || item.ExpiresAt.Value >= nowUtc))
.CountAsync(cancellationToken);
return new PunchCardTemplateStatsSnapshot
{
OnSaleCount = onSaleCount,
TotalSoldCount = summary?.TotalSoldCount ?? 0,
TotalRevenueAmount = summary?.TotalRevenueAmount ?? 0m,
ActiveInUseCount = activeInUseCount
};
}
/// <inheritdoc />
public Task AddTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default)
{
return context.PunchCardTemplates.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default)
{
context.PunchCardTemplates.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteTemplateAsync(PunchCardTemplate entity, CancellationToken cancellationToken = default)
{
context.PunchCardTemplates.Remove(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<PunchCardInstance?> FindInstanceByNoAsync(
long tenantId,
long storeId,
string instanceNo,
CancellationToken cancellationToken = default)
{
return context.PunchCardInstances
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.InstanceNo == instanceNo)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<PunchCardInstance?> FindInstanceByIdAsync(
long tenantId,
long storeId,
long instanceId,
CancellationToken cancellationToken = default)
{
return context.PunchCardInstances
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Id == instanceId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PunchCardInstance>> GetInstancesByIdsAsync(
long tenantId,
long storeId,
IReadOnlyCollection<long> instanceIds,
CancellationToken cancellationToken = default)
{
if (instanceIds.Count == 0)
{
return [];
}
return await context.PunchCardInstances
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
instanceIds.Contains(item.Id))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddInstanceAsync(PunchCardInstance entity, CancellationToken cancellationToken = default)
{
return context.PunchCardInstances.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateInstanceAsync(PunchCardInstance entity, CancellationToken cancellationToken = default)
{
context.PunchCardInstances.Update(entity);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<(IReadOnlyList<PunchCardUsageRecord> Items, int TotalCount)> SearchUsageRecordsAsync(
long tenantId,
long storeId,
long? templateId,
string? keyword,
PunchCardUsageRecordFilterStatus? status,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 500);
var query = BuildUsageRecordQuery(
tenantId,
storeId,
templateId,
keyword,
status,
DateTime.UtcNow);
var totalCount = await query.CountAsync(cancellationToken);
if (totalCount == 0)
{
return ([], 0);
}
var items = await query
.OrderByDescending(item => item.UsedAt)
.ThenByDescending(item => item.Id)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, totalCount);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PunchCardUsageRecord>> ListUsageRecordsForExportAsync(
long tenantId,
long storeId,
long? templateId,
string? keyword,
PunchCardUsageRecordFilterStatus? status,
CancellationToken cancellationToken = default)
{
return await BuildUsageRecordQuery(
tenantId,
storeId,
templateId,
keyword,
status,
DateTime.UtcNow)
.OrderByDescending(item => item.UsedAt)
.ThenByDescending(item => item.Id)
.Take(20_000)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<PunchCardUsageStatsSnapshot> GetUsageStatsAsync(
long tenantId,
long storeId,
long? templateId,
DateTime nowUtc,
CancellationToken cancellationToken = default)
{
var utcNow = NormalizeUtc(nowUtc);
var todayStart = utcNow.Date;
var monthStart = new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var soonEnd = todayStart.AddDays(7);
var usageQuery = context.PunchCardUsageRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
if (templateId.HasValue)
{
usageQuery = usageQuery.Where(item => item.PunchCardTemplateId == templateId.Value);
}
var todayUsedCount = await usageQuery
.Where(item => item.UsedAt >= todayStart && item.UsedAt < todayStart.AddDays(1))
.CountAsync(cancellationToken);
var monthUsedCount = await usageQuery
.Where(item => item.UsedAt >= monthStart && item.UsedAt < monthStart.AddMonths(1))
.CountAsync(cancellationToken);
var instanceQuery = context.PunchCardInstances
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
item.Status == PunchCardInstanceStatus.Active &&
item.RemainingTimes > 0 &&
item.ExpiresAt.HasValue &&
item.ExpiresAt.Value >= todayStart &&
item.ExpiresAt.Value < soonEnd);
if (templateId.HasValue)
{
instanceQuery = instanceQuery.Where(item => item.PunchCardTemplateId == templateId.Value);
}
var expiringSoonCount = await instanceQuery.CountAsync(cancellationToken);
return new PunchCardUsageStatsSnapshot
{
TodayUsedCount = todayUsedCount,
MonthUsedCount = monthUsedCount,
ExpiringSoonCount = expiringSoonCount
};
}
/// <inheritdoc />
public Task AddUsageRecordAsync(PunchCardUsageRecord entity, CancellationToken cancellationToken = default)
{
return context.PunchCardUsageRecords.AddAsync(entity, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
private IQueryable<PunchCardUsageRecord> BuildUsageRecordQuery(
long tenantId,
long storeId,
long? templateId,
string? keyword,
PunchCardUsageRecordFilterStatus? status,
DateTime nowUtc)
{
var query = context.PunchCardUsageRecords
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
if (templateId.HasValue)
{
query = query.Where(item => item.PunchCardTemplateId == templateId.Value);
}
var instanceQuery = context.PunchCardInstances
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.StoreId == storeId);
if (templateId.HasValue)
{
instanceQuery = instanceQuery.Where(item => item.PunchCardTemplateId == templateId.Value);
}
var utcNow = NormalizeUtc(nowUtc);
if (status.HasValue)
{
instanceQuery = status.Value switch
{
PunchCardUsageRecordFilterStatus.Normal => instanceQuery.Where(item =>
item.Status == PunchCardInstanceStatus.Active &&
item.RemainingTimes > 0 &&
(!item.ExpiresAt.HasValue || item.ExpiresAt.Value >= utcNow)),
PunchCardUsageRecordFilterStatus.UsedUp => instanceQuery.Where(item =>
item.Status == PunchCardInstanceStatus.UsedUp ||
item.RemainingTimes <= 0),
PunchCardUsageRecordFilterStatus.Expired => instanceQuery.Where(item =>
(item.Status == PunchCardInstanceStatus.Expired ||
(item.ExpiresAt.HasValue && item.ExpiresAt.Value < utcNow)) &&
item.Status != PunchCardInstanceStatus.Refunded),
_ => instanceQuery
};
}
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalizedKeyword = $"%{keyword.Trim()}%";
var matchedInstanceIds = context.PunchCardInstances
.AsNoTracking()
.Where(item =>
item.TenantId == tenantId &&
item.StoreId == storeId &&
(EF.Functions.ILike(item.MemberName, normalizedKeyword) ||
EF.Functions.ILike(item.MemberPhoneMasked, normalizedKeyword) ||
EF.Functions.ILike(item.InstanceNo, normalizedKeyword)))
.Select(item => item.Id);
query = query.Where(item =>
EF.Functions.ILike(item.RecordNo, normalizedKeyword) ||
EF.Functions.ILike(item.ProductName, normalizedKeyword) ||
matchedInstanceIds.Contains(item.PunchCardInstanceId));
}
if (status.HasValue)
{
var filteredInstanceIds = instanceQuery.Select(item => item.Id);
query = query.Where(item => filteredInstanceIds.Contains(item.PunchCardInstanceId));
}
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,177 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPunchCardModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "punch_card_instances",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
PunchCardTemplateId = table.Column<long>(type: "bigint", nullable: false, comment: "次卡模板 ID。"),
InstanceNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "实例编号(业务唯一)。"),
MemberName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "会员名称。"),
MemberPhoneMasked = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "会员手机号(脱敏)。"),
PurchasedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "购买时间UTC。"),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "过期时间UTC可空。"),
TotalTimes = table.Column<int>(type: "integer", nullable: false, comment: "总次数。"),
RemainingTimes = table.Column<int>(type: "integer", nullable: false, comment: "剩余次数。"),
PaidAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "实付金额。"),
Status = table.Column<int>(type: "integer", 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_punch_card_instances", x => x.Id);
},
comment: "次卡实例(顾客购买后生成)。");
migrationBuilder.CreateTable(
name: "punch_card_templates",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "次卡名称。"),
CoverImageUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "封面图片地址。"),
SalePrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "售价。"),
OriginalPrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "原价。"),
TotalTimes = table.Column<int>(type: "integer", nullable: false, comment: "总次数。"),
ValidityType = table.Column<int>(type: "integer", nullable: false, comment: "有效期类型。"),
ValidityDays = table.Column<int>(type: "integer", nullable: true, comment: "固定天数ValidityType=Days 时有效)。"),
ValidFrom = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "固定开始日期UTCValidityType=DateRange 时有效)。"),
ValidTo = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "固定结束日期UTCValidityType=DateRange 时有效)。"),
ScopeType = table.Column<int>(type: "integer", nullable: false, comment: "适用范围类型。"),
ScopeCategoryIdsJson = table.Column<string>(type: "text", nullable: false, comment: "指定分类 ID JSON。"),
ScopeTagIdsJson = table.Column<string>(type: "text", nullable: false, comment: "指定标签 ID JSON。"),
ScopeProductIdsJson = table.Column<string>(type: "text", nullable: false, comment: "指定商品 ID JSON。"),
UsageMode = table.Column<int>(type: "integer", nullable: false, comment: "使用模式。"),
UsageCapAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "金额上限UsageMode=Cap 时有效)。"),
DailyLimit = table.Column<int>(type: "integer", nullable: true, comment: "每日限用次数。"),
PerOrderLimit = table.Column<int>(type: "integer", nullable: true, comment: "每单限用次数。"),
PerUserPurchaseLimit = table.Column<int>(type: "integer", nullable: true, comment: "每人限购张数。"),
AllowTransfer = table.Column<bool>(type: "boolean", nullable: false, comment: "是否允许转赠。"),
ExpireStrategy = table.Column<int>(type: "integer", nullable: false, comment: "过期策略。"),
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "次卡描述。"),
NotifyChannelsJson = table.Column<string>(type: "text", nullable: false, comment: "购买通知渠道 JSON。"),
Status = table.Column<int>(type: "integer", 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_punch_card_templates", x => x.Id);
},
comment: "次卡模板配置。");
migrationBuilder.CreateTable(
name: "punch_card_usage_records",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
StoreId = table.Column<long>(type: "bigint", nullable: false, comment: "门店 ID。"),
PunchCardTemplateId = table.Column<long>(type: "bigint", nullable: false, comment: "次卡模板 ID。"),
PunchCardInstanceId = table.Column<long>(type: "bigint", nullable: false, comment: "次卡实例 ID。"),
RecordNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, comment: "使用单号。"),
ProductName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "兑换商品名称。"),
UsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "使用时间UTC。"),
UsedTimes = table.Column<int>(type: "integer", nullable: false, comment: "本次使用次数。"),
RemainingTimesAfterUse = table.Column<int>(type: "integer", nullable: false, comment: "使用后剩余次数。"),
StatusAfterUse = table.Column<int>(type: "integer", nullable: false, comment: "本次记录状态。"),
ExtraPayAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, 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_punch_card_usage_records", x => x.Id);
},
comment: "次卡使用记录。");
migrationBuilder.CreateIndex(
name: "IX_punch_card_instances_TenantId_StoreId_InstanceNo",
table: "punch_card_instances",
columns: new[] { "TenantId", "StoreId", "InstanceNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_punch_card_instances_TenantId_StoreId_PunchCardTemplateId",
table: "punch_card_instances",
columns: new[] { "TenantId", "StoreId", "PunchCardTemplateId" });
migrationBuilder.CreateIndex(
name: "IX_punch_card_instances_TenantId_StoreId_Status_ExpiresAt",
table: "punch_card_instances",
columns: new[] { "TenantId", "StoreId", "Status", "ExpiresAt" });
migrationBuilder.CreateIndex(
name: "IX_punch_card_templates_TenantId_StoreId_Name",
table: "punch_card_templates",
columns: new[] { "TenantId", "StoreId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_punch_card_templates_TenantId_StoreId_Status",
table: "punch_card_templates",
columns: new[] { "TenantId", "StoreId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_punch_card_usage_records_TenantId_StoreId_PunchCardInstance~",
table: "punch_card_usage_records",
columns: new[] { "TenantId", "StoreId", "PunchCardInstanceId", "UsedAt" });
migrationBuilder.CreateIndex(
name: "IX_punch_card_usage_records_TenantId_StoreId_PunchCardTemplate~",
table: "punch_card_usage_records",
columns: new[] { "TenantId", "StoreId", "PunchCardTemplateId", "UsedAt" });
migrationBuilder.CreateIndex(
name: "IX_punch_card_usage_records_TenantId_StoreId_RecordNo",
table: "punch_card_usage_records",
columns: new[] { "TenantId", "StoreId", "RecordNo" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "punch_card_instances");
migrationBuilder.DropTable(
name: "punch_card_templates");
migrationBuilder.DropTable(
name: "punch_card_usage_records");
}
}
}

View File

@@ -1012,6 +1012,363 @@ namespace TakeoutSaaS.Infrastructure.Migrations
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PunchCardInstance", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasComment("过期时间UTC可空。");
b.Property<string>("InstanceNo")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("实例编号(业务唯一)。");
b.Property<string>("MemberName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("会员名称。");
b.Property<string>("MemberPhoneMasked")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("会员手机号(脱敏)。");
b.Property<decimal>("PaidAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("实付金额。");
b.Property<long>("PunchCardTemplateId")
.HasColumnType("bigint")
.HasComment("次卡模板 ID。");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("timestamp with time zone")
.HasComment("购买时间UTC。");
b.Property<int>("RemainingTimes")
.HasColumnType("integer")
.HasComment("剩余次数。");
b.Property<int>("Status")
.HasColumnType("integer")
.HasComment("实例状态。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<int>("TotalTimes")
.HasColumnType("integer")
.HasComment("总次数。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "InstanceNo")
.IsUnique();
b.HasIndex("TenantId", "StoreId", "PunchCardTemplateId");
b.HasIndex("TenantId", "StoreId", "Status", "ExpiresAt");
b.ToTable("punch_card_instances", null, t =>
{
t.HasComment("次卡实例(顾客购买后生成)。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PunchCardTemplate", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<bool>("AllowTransfer")
.HasColumnType("boolean")
.HasComment("是否允许转赠。");
b.Property<string>("CoverImageUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("封面图片地址。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<int?>("DailyLimit")
.HasColumnType("integer")
.HasComment("每日限用次数。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("Description")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("次卡描述。");
b.Property<int>("ExpireStrategy")
.HasColumnType("integer")
.HasComment("过期策略。");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("次卡名称。");
b.Property<string>("NotifyChannelsJson")
.IsRequired()
.HasColumnType("text")
.HasComment("购买通知渠道 JSON。");
b.Property<decimal?>("OriginalPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("原价。");
b.Property<int?>("PerOrderLimit")
.HasColumnType("integer")
.HasComment("每单限用次数。");
b.Property<int?>("PerUserPurchaseLimit")
.HasColumnType("integer")
.HasComment("每人限购张数。");
b.Property<decimal>("SalePrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("售价。");
b.Property<string>("ScopeCategoryIdsJson")
.IsRequired()
.HasColumnType("text")
.HasComment("指定分类 ID JSON。");
b.Property<string>("ScopeProductIdsJson")
.IsRequired()
.HasColumnType("text")
.HasComment("指定商品 ID JSON。");
b.Property<string>("ScopeTagIdsJson")
.IsRequired()
.HasColumnType("text")
.HasComment("指定标签 ID JSON。");
b.Property<int>("ScopeType")
.HasColumnType("integer")
.HasComment("适用范围类型。");
b.Property<int>("Status")
.HasColumnType("integer")
.HasComment("次卡状态。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<int>("TotalTimes")
.HasColumnType("integer")
.HasComment("总次数。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.Property<decimal?>("UsageCapAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("金额上限UsageMode=Cap 时有效)。");
b.Property<int>("UsageMode")
.HasColumnType("integer")
.HasComment("使用模式。");
b.Property<DateTime?>("ValidFrom")
.HasColumnType("timestamp with time zone")
.HasComment("固定开始日期UTCValidityType=DateRange 时有效)。");
b.Property<DateTime?>("ValidTo")
.HasColumnType("timestamp with time zone")
.HasComment("固定结束日期UTCValidityType=DateRange 时有效)。");
b.Property<int?>("ValidityDays")
.HasColumnType("integer")
.HasComment("固定天数ValidityType=Days 时有效)。");
b.Property<int>("ValidityType")
.HasColumnType("integer")
.HasComment("有效期类型。");
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "Name")
.IsUnique();
b.HasIndex("TenantId", "StoreId", "Status");
b.ToTable("punch_card_templates", null, t =>
{
t.HasComment("次卡模板配置。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PunchCardUsageRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<decimal?>("ExtraPayAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasComment("超额补差金额。");
b.Property<string>("ProductName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComment("兑换商品名称。");
b.Property<long>("PunchCardInstanceId")
.HasColumnType("bigint")
.HasComment("次卡实例 ID。");
b.Property<long>("PunchCardTemplateId")
.HasColumnType("bigint")
.HasComment("次卡模板 ID。");
b.Property<string>("RecordNo")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasComment("使用单号。");
b.Property<int>("RemainingTimesAfterUse")
.HasColumnType("integer")
.HasComment("使用后剩余次数。");
b.Property<int>("StatusAfterUse")
.HasColumnType("integer")
.HasComment("本次记录状态。");
b.Property<long>("StoreId")
.HasColumnType("bigint")
.HasComment("门店 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime>("UsedAt")
.HasColumnType("timestamp with time zone")
.HasComment("使用时间UTC。");
b.Property<int>("UsedTimes")
.HasColumnType("integer")
.HasComment("本次使用次数。");
b.HasKey("Id");
b.HasIndex("TenantId", "StoreId", "RecordNo")
.IsUnique();
b.HasIndex("TenantId", "StoreId", "PunchCardInstanceId", "UsedAt");
b.HasIndex("TenantId", "StoreId", "PunchCardTemplateId", "UsedAt");
b.ToTable("punch_card_usage_records", null, t =>
{
t.HasComment("次卡使用记录。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatMessage", b =>
{
b.Property<long>("Id")