feat: implement tenant member stored card module
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m24s

This commit is contained in:
2026-03-04 09:14:57 +08:00
parent d96ca4971a
commit 2970134200
35 changed files with 12805 additions and 1 deletions

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Commands;
/// <summary>
/// 修改储值卡方案状态命令。
/// </summary>
public sealed class ChangeStoredCardPlanStatusCommand : IRequest<MemberStoredCardPlanDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 方案标识。
/// </summary>
public long PlanId { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "disabled";
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Commands;
/// <summary>
/// 删除储值卡方案命令。
/// </summary>
public sealed class DeleteStoredCardPlanCommand : IRequest
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 方案标识。
/// </summary>
public long PlanId { get; init; }
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Commands;
/// <summary>
/// 保存储值卡方案命令。
/// </summary>
public sealed class SaveStoredCardPlanCommand : IRequest<MemberStoredCardPlanDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 方案标识(编辑时传)。
/// </summary>
public long? PlanId { get; init; }
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; init; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; init; }
/// <summary>
/// 排序值。
/// </summary>
public int SortOrder { get; init; } = 100;
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Commands;
/// <summary>
/// 写入储值卡充值记录命令。
/// </summary>
public sealed class WriteStoredCardRechargeRecordCommand : IRequest<MemberStoredCardRechargeRecordDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; init; }
/// <summary>
/// 方案标识(可空)。
/// </summary>
public long? PlanId { get; init; }
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; init; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; init; }
/// <summary>
/// 支付方式wechat/alipay/cash/card/balance
/// </summary>
public string PaymentMethod { get; init; } = "wechat";
/// <summary>
/// 充值时间(可空,默认当前时间)。
/// </summary>
public DateTime? RechargedAt { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; init; }
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Application.App.Members.StoredCard.Dto;
/// <summary>
/// 储值卡方案 DTO。
/// </summary>
public sealed class MemberStoredCardPlanDto
{
/// <summary>
/// 方案标识。
/// </summary>
public long PlanId { get; init; }
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; init; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; init; }
/// <summary>
/// 到账金额(充值+赠送)。
/// </summary>
public decimal ArrivedAmount { get; init; }
/// <summary>
/// 排序。
/// </summary>
public int SortOrder { get; init; }
/// <summary>
/// 状态enabled/disabled
/// </summary>
public string Status { get; init; } = "enabled";
/// <summary>
/// 累计充值次数。
/// </summary>
public int RechargeCount { get; init; }
/// <summary>
/// 累计充值金额。
/// </summary>
public decimal TotalRechargeAmount { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.App.Members.StoredCard.Dto;
/// <summary>
/// 储值卡方案列表结果 DTO。
/// </summary>
public sealed class MemberStoredCardPlanListResultDto
{
/// <summary>
/// 方案列表。
/// </summary>
public IReadOnlyList<MemberStoredCardPlanDto> Items { get; init; } = [];
/// <summary>
/// 页面统计。
/// </summary>
public MemberStoredCardPlanStatsDto Stats { get; init; } = new();
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Members.StoredCard.Dto;
/// <summary>
/// 储值卡方案页统计 DTO。
/// </summary>
public sealed class MemberStoredCardPlanStatsDto
{
/// <summary>
/// 储值总额。
/// </summary>
public decimal TotalRechargeAmount { get; init; }
/// <summary>
/// 赠金总额。
/// </summary>
public decimal TotalGiftAmount { get; init; }
/// <summary>
/// 本月充值。
/// </summary>
public decimal CurrentMonthRechargeAmount { get; init; }
/// <summary>
/// 储值用户数。
/// </summary>
public int RechargeMemberCount { get; init; }
}

View File

@@ -0,0 +1,67 @@
namespace TakeoutSaaS.Application.App.Members.StoredCard.Dto;
/// <summary>
/// 储值卡充值记录 DTO。
/// </summary>
public sealed class MemberStoredCardRechargeRecordDto
{
/// <summary>
/// 记录标识。
/// </summary>
public long RecordId { get; init; }
/// <summary>
/// 充值单号。
/// </summary>
public string RecordNo { get; init; } = string.Empty;
/// <summary>
/// 会员标识。
/// </summary>
public long MemberId { get; init; }
/// <summary>
/// 会员名称。
/// </summary>
public string MemberName { get; init; } = string.Empty;
/// <summary>
/// 手机号(脱敏)。
/// </summary>
public string MemberMobileMasked { get; init; } = string.Empty;
/// <summary>
/// 充值金额。
/// </summary>
public decimal RechargeAmount { get; init; }
/// <summary>
/// 赠送金额。
/// </summary>
public decimal GiftAmount { get; init; }
/// <summary>
/// 到账金额。
/// </summary>
public decimal ArrivedAmount { get; init; }
/// <summary>
/// 支付方式wechat/alipay/cash/card/balance/unknown
/// </summary>
public string PaymentMethod { get; init; } = "unknown";
/// <summary>
/// 充值时间UTC
/// </summary>
public DateTime RechargedAt { get; init; }
/// <summary>
/// 方案标识。
/// </summary>
public long? PlanId { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Members.StoredCard.Dto;
/// <summary>
/// 储值卡充值记录导出 DTO。
/// </summary>
public sealed class MemberStoredCardRechargeRecordExportDto
{
/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; init; } = string.Empty;
/// <summary>
/// 文件内容 Base64。
/// </summary>
public string FileContentBase64 { get; init; } = string.Empty;
/// <summary>
/// 导出总数。
/// </summary>
public int TotalCount { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Members.StoredCard.Dto;
/// <summary>
/// 储值卡充值记录列表结果 DTO。
/// </summary>
public sealed class MemberStoredCardRechargeRecordListResultDto
{
/// <summary>
/// 列表项。
/// </summary>
public IReadOnlyList<MemberStoredCardRechargeRecordDto> Items { get; init; } = [];
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; }
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; }
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; init; }
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Handlers;
/// <summary>
/// 修改储值卡方案状态处理器。
/// </summary>
public sealed class ChangeStoredCardPlanStatusCommandHandler(
IStoredCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<ChangeStoredCardPlanStatusCommand, MemberStoredCardPlanDto>
{
/// <inheritdoc />
public async Task<MemberStoredCardPlanDto> Handle(
ChangeStoredCardPlanStatusCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var status = MemberStoredCardMapping.ParsePlanStatus(request.Status);
var entity = await repository.FindPlanByIdAsync(
tenantId,
request.StoreId,
request.PlanId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "储值卡方案不存在");
entity.Status = status;
await repository.UpdatePlanAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
var aggregates = await repository.GetPlanAggregatesAsync(
tenantId,
request.StoreId,
[entity.Id],
cancellationToken);
var aggregate = aggregates.TryGetValue(entity.Id, out var value)
? value
: MemberStoredCardDtoFactory.EmptyAggregate(entity.Id);
return MemberStoredCardDtoFactory.ToPlanDto(entity, aggregate);
}
}

View File

@@ -0,0 +1,33 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Handlers;
/// <summary>
/// 删除储值卡方案处理器。
/// </summary>
public sealed class DeleteStoredCardPlanCommandHandler(
IStoredCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<DeleteStoredCardPlanCommand>
{
/// <inheritdoc />
public async Task Handle(DeleteStoredCardPlanCommand request, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var entity = await repository.FindPlanByIdAsync(
tenantId,
request.StoreId,
request.PlanId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "储值卡方案不存在");
await repository.DeletePlanAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,74 @@
using System.Text;
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Application.App.Members.StoredCard.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Handlers;
/// <summary>
/// 导出储值卡充值记录处理器。
/// </summary>
public sealed class ExportStoredCardRechargeRecordCsvQueryHandler(
IStoredCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<ExportStoredCardRechargeRecordCsvQuery, MemberStoredCardRechargeRecordExportDto>
{
/// <inheritdoc />
public async Task<MemberStoredCardRechargeRecordExportDto> Handle(
ExportStoredCardRechargeRecordCsvQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedKeyword = MemberStoredCardMapping.NormalizeKeyword(request.Keyword);
var (startUtc, endUtc) = MemberStoredCardMapping.NormalizeDateRange(request.StartDateUtc, request.EndDateUtc);
var records = await repository.ListRechargeRecordsForExportAsync(
tenantId,
request.StoreId,
startUtc,
endUtc,
normalizedKeyword,
cancellationToken);
var csv = BuildCsv(records);
var bytes = Encoding.UTF8.GetBytes($"\uFEFF{csv}");
return new MemberStoredCardRechargeRecordExportDto
{
FileName = $"储值卡充值记录_{DateTime.UtcNow:yyyyMMddHHmmss}.csv",
FileContentBase64 = Convert.ToBase64String(bytes),
TotalCount = records.Count
};
}
private static string BuildCsv(IReadOnlyCollection<Domain.Membership.Entities.MemberStoredCardRechargeRecord> records)
{
var lines = new List<string>
{
"充值单号,会员,手机号,充值金额,赠送金额,到账金额,支付方式,充值时间"
};
foreach (var item in records)
{
lines.Add(string.Join(",",
Escape(item.RecordNo),
Escape(item.MemberName),
Escape(item.MemberMobileMasked),
item.RechargeAmount.ToString("0.00"),
item.GiftAmount.ToString("0.00"),
item.ArrivedAmount.ToString("0.00"),
Escape(MemberStoredCardMapping.ToPaymentMethodDisplayText(item.PaymentMethod)),
Escape(item.RechargedAt.ToString("yyyy-MM-dd HH:mm:ss"))));
}
return string.Join('\n', lines);
}
private static string Escape(string value)
{
var text = value.Replace("\"", "\"\"");
return $"\"{text}\"";
}
}

View File

@@ -0,0 +1,59 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Application.App.Members.StoredCard.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Handlers;
/// <summary>
/// 储值卡方案列表查询处理器。
/// </summary>
public sealed class GetStoredCardPlanListQueryHandler(
IStoredCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetStoredCardPlanListQuery, MemberStoredCardPlanListResultDto>
{
/// <inheritdoc />
public async Task<MemberStoredCardPlanListResultDto> Handle(
GetStoredCardPlanListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var plans = await repository.GetPlansByStoreAsync(
tenantId,
request.StoreId,
cancellationToken);
var planIds = plans.Select(item => item.Id).ToList();
var aggregates = await repository.GetPlanAggregatesAsync(
tenantId,
request.StoreId,
planIds,
cancellationToken);
var items = plans
.Select(item =>
{
var aggregate = aggregates.TryGetValue(item.Id, out var value)
? value
: MemberStoredCardDtoFactory.EmptyAggregate(item.Id);
return MemberStoredCardDtoFactory.ToPlanDto(item, aggregate);
})
.ToList();
var stats = await repository.GetPlanStatsAsync(
tenantId,
request.StoreId,
DateTime.UtcNow,
cancellationToken);
return new MemberStoredCardPlanListResultDto
{
Items = items,
Stats = MemberStoredCardDtoFactory.ToPlanStatsDto(stats)
};
}
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Application.App.Members.StoredCard.Queries;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Handlers;
/// <summary>
/// 储值卡充值记录列表查询处理器。
/// </summary>
public sealed class GetStoredCardRechargeRecordListQueryHandler(
IStoredCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<GetStoredCardRechargeRecordListQuery, MemberStoredCardRechargeRecordListResultDto>
{
/// <inheritdoc />
public async Task<MemberStoredCardRechargeRecordListResultDto> Handle(
GetStoredCardRechargeRecordListQuery request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 200);
var normalizedKeyword = MemberStoredCardMapping.NormalizeKeyword(request.Keyword);
var (startUtc, endUtc) = MemberStoredCardMapping.NormalizeDateRange(request.StartDateUtc, request.EndDateUtc);
var (items, totalCount) = await repository.SearchRechargeRecordsAsync(
tenantId,
request.StoreId,
startUtc,
endUtc,
normalizedKeyword,
page,
pageSize,
cancellationToken);
return new MemberStoredCardRechargeRecordListResultDto
{
Items = items.Select(MemberStoredCardDtoFactory.ToRechargeRecordDto).ToList(),
Page = page,
PageSize = pageSize,
TotalCount = totalCount
};
}
}

View File

@@ -0,0 +1,88 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Handlers;
/// <summary>
/// 保存储值卡方案处理器。
/// </summary>
public sealed class SaveStoredCardPlanCommandHandler(
IStoredCardRepository repository,
ITenantProvider tenantProvider)
: IRequestHandler<SaveStoredCardPlanCommand, MemberStoredCardPlanDto>
{
/// <inheritdoc />
public async Task<MemberStoredCardPlanDto> Handle(
SaveStoredCardPlanCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var normalizedRechargeAmount = MemberStoredCardMapping.NormalizeRechargeAmount(request.RechargeAmount);
var normalizedGiftAmount = MemberStoredCardMapping.NormalizeGiftAmount(request.GiftAmount);
var normalizedSortOrder = MemberStoredCardMapping.NormalizeSortOrder(request.SortOrder);
var status = MemberStoredCardMapping.ParsePlanStatus(request.Status);
var allPlans = await repository.GetPlansByStoreAsync(
tenantId,
request.StoreId,
cancellationToken);
var duplicated = allPlans.Any(item =>
item.Id != request.PlanId &&
item.RechargeAmount == normalizedRechargeAmount &&
item.GiftAmount == normalizedGiftAmount);
if (duplicated)
{
throw new BusinessException(ErrorCodes.BadRequest, "已存在相同充值金额与赠送金额的方案");
}
Domain.Membership.Entities.MemberStoredCardPlan entity;
if (request.PlanId.HasValue && request.PlanId.Value > 0)
{
entity = await repository.FindPlanByIdAsync(
tenantId,
request.StoreId,
request.PlanId.Value,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "储值卡方案不存在");
entity.RechargeAmount = normalizedRechargeAmount;
entity.GiftAmount = normalizedGiftAmount;
entity.SortOrder = normalizedSortOrder;
entity.Status = status;
await repository.UpdatePlanAsync(entity, cancellationToken);
}
else
{
entity = MemberStoredCardDtoFactory.CreatePlanEntity(
request,
normalizedRechargeAmount,
normalizedGiftAmount,
normalizedSortOrder,
status);
await repository.AddPlanAsync(entity, cancellationToken);
}
await repository.SaveChangesAsync(cancellationToken);
var aggregates = await repository.GetPlanAggregatesAsync(
tenantId,
request.StoreId,
[entity.Id],
cancellationToken);
var aggregate = aggregates.TryGetValue(entity.Id, out var value)
? value
: MemberStoredCardDtoFactory.EmptyAggregate(entity.Id);
return MemberStoredCardDtoFactory.ToPlanDto(entity, aggregate);
}
}

View File

@@ -0,0 +1,90 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Membership.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Handlers;
/// <summary>
/// 写入储值卡充值记录处理器。
/// </summary>
public sealed class WriteStoredCardRechargeRecordCommandHandler(
IStoredCardRepository storedCardRepository,
IMemberRepository memberRepository,
ITenantProvider tenantProvider)
: IRequestHandler<WriteStoredCardRechargeRecordCommand, MemberStoredCardRechargeRecordDto>
{
/// <inheritdoc />
public async Task<MemberStoredCardRechargeRecordDto> Handle(
WriteStoredCardRechargeRecordCommand request,
CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var rechargeAmount = MemberStoredCardMapping.NormalizeRechargeAmount(request.RechargeAmount);
var giftAmount = MemberStoredCardMapping.NormalizeGiftAmount(request.GiftAmount);
var paymentMethod = MemberStoredCardMapping.ParsePaymentMethod(request.PaymentMethod);
var remark = MemberStoredCardMapping.NormalizeOptionalRemark(request.Remark);
var rechargedAt = request.RechargedAt.HasValue
? MemberStoredCardMapping.NormalizeUtc(request.RechargedAt.Value)
: DateTime.UtcNow;
MemberStoredCardPlan? plan = null;
if (request.PlanId.HasValue && request.PlanId.Value > 0)
{
plan = await storedCardRepository.FindPlanByIdAsync(
tenantId,
request.StoreId,
request.PlanId.Value,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "储值卡方案不存在");
if (plan.Status != MemberStoredCardPlanStatus.Enabled)
{
throw new BusinessException(ErrorCodes.BadRequest, "储值卡方案未启用");
}
if (plan.RechargeAmount != rechargeAmount || plan.GiftAmount != giftAmount)
{
throw new BusinessException(ErrorCodes.BadRequest, "充值金额与储值卡方案不一致");
}
}
var member = await memberRepository.FindProfileByIdAsync(
tenantId,
request.MemberId,
cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "会员不存在");
var arrivedAmount = decimal.Round(rechargeAmount + giftAmount, 2, MidpointRounding.AwayFromZero);
var record = new MemberStoredCardRechargeRecord
{
StoreId = request.StoreId,
MemberId = member.Id,
PlanId = plan?.Id,
RecordNo = MemberStoredCardMapping.BuildRechargeRecordNo(rechargedAt),
MemberName = MemberStoredCardMapping.ResolveMemberName(member),
MemberMobileMasked = MemberStoredCardMapping.ResolveMemberMobileMasked(member),
RechargeAmount = rechargeAmount,
GiftAmount = giftAmount,
ArrivedAmount = arrivedAmount,
PaymentMethod = paymentMethod,
Remark = remark,
RechargedAt = rechargedAt
};
member.StoredRechargeBalance = decimal.Round(member.StoredRechargeBalance + rechargeAmount, 2, MidpointRounding.AwayFromZero);
member.StoredGiftBalance = decimal.Round(member.StoredGiftBalance + giftAmount, 2, MidpointRounding.AwayFromZero);
member.StoredBalance = decimal.Round(member.StoredRechargeBalance + member.StoredGiftBalance, 2, MidpointRounding.AwayFromZero);
await memberRepository.UpdateProfileAsync(member, cancellationToken);
await storedCardRepository.AddRechargeRecordAsync(record, cancellationToken);
await storedCardRepository.SaveChangesAsync(cancellationToken);
return MemberStoredCardDtoFactory.ToRechargeRecordDto(record);
}
}

View File

@@ -0,0 +1,87 @@
using TakeoutSaaS.Application.App.Members.StoredCard.Commands;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Membership.Repositories;
namespace TakeoutSaaS.Application.App.Members.StoredCard;
/// <summary>
/// 储值卡 DTO 构造器。
/// </summary>
internal static class MemberStoredCardDtoFactory
{
public static MemberStoredCardPlanAggregateSnapshot EmptyAggregate(long planId)
{
return new MemberStoredCardPlanAggregateSnapshot
{
PlanId = planId,
RechargeCount = 0,
TotalRechargeAmount = 0m
};
}
public static MemberStoredCardPlanDto ToPlanDto(
MemberStoredCardPlan source,
MemberStoredCardPlanAggregateSnapshot aggregate)
{
return new MemberStoredCardPlanDto
{
PlanId = source.Id,
RechargeAmount = decimal.Round(source.RechargeAmount, 2, MidpointRounding.AwayFromZero),
GiftAmount = decimal.Round(source.GiftAmount, 2, MidpointRounding.AwayFromZero),
ArrivedAmount = decimal.Round(source.RechargeAmount + source.GiftAmount, 2, MidpointRounding.AwayFromZero),
SortOrder = source.SortOrder,
Status = MemberStoredCardMapping.ToPlanStatusText(source.Status),
RechargeCount = aggregate.RechargeCount,
TotalRechargeAmount = decimal.Round(aggregate.TotalRechargeAmount, 2, MidpointRounding.AwayFromZero)
};
}
public static MemberStoredCardPlanStatsDto ToPlanStatsDto(MemberStoredCardPlanStatsSnapshot source)
{
return new MemberStoredCardPlanStatsDto
{
TotalRechargeAmount = decimal.Round(source.TotalRechargeAmount, 2, MidpointRounding.AwayFromZero),
TotalGiftAmount = decimal.Round(source.TotalGiftAmount, 2, MidpointRounding.AwayFromZero),
CurrentMonthRechargeAmount = decimal.Round(source.CurrentMonthRechargeAmount, 2, MidpointRounding.AwayFromZero),
RechargeMemberCount = source.RechargeMemberCount
};
}
public static MemberStoredCardRechargeRecordDto ToRechargeRecordDto(MemberStoredCardRechargeRecord source)
{
return new MemberStoredCardRechargeRecordDto
{
RecordId = source.Id,
RecordNo = source.RecordNo,
MemberId = source.MemberId,
MemberName = source.MemberName,
MemberMobileMasked = source.MemberMobileMasked,
RechargeAmount = decimal.Round(source.RechargeAmount, 2, MidpointRounding.AwayFromZero),
GiftAmount = decimal.Round(source.GiftAmount, 2, MidpointRounding.AwayFromZero),
ArrivedAmount = decimal.Round(source.ArrivedAmount, 2, MidpointRounding.AwayFromZero),
PaymentMethod = MemberStoredCardMapping.ToPaymentMethodText(source.PaymentMethod),
RechargedAt = source.RechargedAt,
PlanId = source.PlanId,
Remark = source.Remark
};
}
public static MemberStoredCardPlan CreatePlanEntity(
SaveStoredCardPlanCommand request,
decimal normalizedRechargeAmount,
decimal normalizedGiftAmount,
int normalizedSortOrder,
MemberStoredCardPlanStatus status)
{
return new MemberStoredCardPlan
{
StoreId = request.StoreId,
RechargeAmount = normalizedRechargeAmount,
GiftAmount = normalizedGiftAmount,
SortOrder = normalizedSortOrder,
Status = status
};
}
}

View File

@@ -0,0 +1,222 @@
using TakeoutSaaS.Domain.Membership.Entities;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Members.StoredCard;
/// <summary>
/// 储值卡模块映射与标准化。
/// </summary>
internal static class MemberStoredCardMapping
{
public static MemberStoredCardPlanStatus ParsePlanStatus(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"enabled" => MemberStoredCardPlanStatus.Enabled,
"disabled" => MemberStoredCardPlanStatus.Disabled,
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
};
}
public static string ToPlanStatusText(MemberStoredCardPlanStatus value)
{
return value switch
{
MemberStoredCardPlanStatus.Enabled => "enabled",
MemberStoredCardPlanStatus.Disabled => "disabled",
_ => "disabled"
};
}
public static PaymentMethod ParsePaymentMethod(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"wechat" => PaymentMethod.WeChatPay,
"alipay" => PaymentMethod.Alipay,
"cash" => PaymentMethod.Cash,
"card" => PaymentMethod.Card,
"balance" => PaymentMethod.Balance,
_ => throw new BusinessException(ErrorCodes.BadRequest, "paymentMethod 参数不合法")
};
}
public static string ToPaymentMethodText(PaymentMethod value)
{
return value switch
{
PaymentMethod.WeChatPay => "wechat",
PaymentMethod.Alipay => "alipay",
PaymentMethod.Cash => "cash",
PaymentMethod.Card => "card",
PaymentMethod.Balance => "balance",
_ => "unknown"
};
}
public static string ToPaymentMethodDisplayText(PaymentMethod value)
{
return value switch
{
PaymentMethod.WeChatPay => "微信支付",
PaymentMethod.Alipay => "支付宝",
PaymentMethod.Cash => "现金",
PaymentMethod.Card => "刷卡",
PaymentMethod.Balance => "余额",
_ => "未知"
};
}
public static decimal NormalizeRechargeAmount(decimal value)
{
if (value <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "rechargeAmount 参数不合法");
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
public static decimal NormalizeGiftAmount(decimal value)
{
if (value < 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "giftAmount 参数不合法");
}
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
}
public static int NormalizeSortOrder(int value)
{
if (value < 0 || value > 9999)
{
throw new BusinessException(ErrorCodes.BadRequest, "sortOrder 参数不合法");
}
return value;
}
public static string? NormalizeKeyword(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
if (normalized.Length > 64)
{
throw new BusinessException(ErrorCodes.BadRequest, "keyword 长度不能超过 64");
}
return normalized;
}
public static string? NormalizeOptionalRemark(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length == 0)
{
return null;
}
if (normalized.Length > 256)
{
throw new BusinessException(ErrorCodes.BadRequest, "remark 长度不能超过 256");
}
return normalized;
}
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 ResolveMemberName(MemberProfile member)
{
var nickname = (member.Nickname ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(nickname))
{
return nickname.Length <= 64 ? nickname : nickname[..64];
}
var mobile = NormalizePhone(member.Mobile);
return mobile.Length >= 4 ? $"会员{mobile[^4..]}" : "会员";
}
public static string ResolveMemberMobileMasked(MemberProfile member)
{
return MaskPhone(NormalizePhone(member.Mobile));
}
public static string BuildRechargeRecordNo(DateTime nowUtc)
{
var utcNow = NormalizeUtc(nowUtc);
return $"CZ{utcNow:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}";
}
private static string NormalizePhone(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var chars = value.Where(char.IsDigit).ToArray();
return chars.Length == 0 ? string.Empty : new string(chars);
}
private static string MaskPhone(string normalizedPhone)
{
if (normalizedPhone.Length >= 11)
{
return $"{normalizedPhone[..3]}****{normalizedPhone[^4..]}";
}
if (normalizedPhone.Length >= 7)
{
return $"{normalizedPhone[..3]}***{normalizedPhone[^2..]}";
}
return normalizedPhone;
}
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Queries;
/// <summary>
/// 导出储值卡充值记录 CSV。
/// </summary>
public sealed class ExportStoredCardRechargeRecordCsvQuery : IRequest<MemberStoredCardRechargeRecordExportDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 开始日期UTC
/// </summary>
public DateTime? StartDateUtc { get; init; }
/// <summary>
/// 结束日期UTC
/// </summary>
public DateTime? EndDateUtc { get; init; }
/// <summary>
/// 关键字(会员/手机号/单号)。
/// </summary>
public string? Keyword { get; init; }
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Queries;
/// <summary>
/// 查询储值卡方案列表。
/// </summary>
public sealed class GetStoredCardPlanListQuery : IRequest<MemberStoredCardPlanListResultDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using TakeoutSaaS.Application.App.Members.StoredCard.Dto;
namespace TakeoutSaaS.Application.App.Members.StoredCard.Queries;
/// <summary>
/// 查询储值卡充值记录分页。
/// </summary>
public sealed class GetStoredCardRechargeRecordListQuery : IRequest<MemberStoredCardRechargeRecordListResultDto>
{
/// <summary>
/// 门店标识。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 开始日期UTC
/// </summary>
public DateTime? StartDateUtc { get; init; }
/// <summary>
/// 结束日期UTC
/// </summary>
public DateTime? EndDateUtc { get; init; }
/// <summary>
/// 关键字(会员/手机号/单号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 8;
}