Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Failing after 1m54s
1109 lines
37 KiB
C#
1109 lines
37 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using TakeoutSaaS.Application.App.Members.Dto;
|
|
using TakeoutSaaS.Domain.Coupons.Entities;
|
|
using TakeoutSaaS.Domain.Coupons.Enums;
|
|
using TakeoutSaaS.Domain.Membership.Entities;
|
|
using TakeoutSaaS.Domain.Membership.Enums;
|
|
using TakeoutSaaS.Domain.Membership.Repositories;
|
|
using TakeoutSaaS.Domain.Orders.Entities;
|
|
using TakeoutSaaS.Domain.Orders.Enums;
|
|
using TakeoutSaaS.Domain.Orders.Repositories;
|
|
using TakeoutSaaS.Domain.Payments.Enums;
|
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
|
|
|
namespace TakeoutSaaS.Application.App.Members.Handlers;
|
|
|
|
internal static class MemberCenterSupport
|
|
{
|
|
private static readonly string[] AvatarColors =
|
|
[
|
|
"#1890ff",
|
|
"#52c41a",
|
|
"#fa8c16",
|
|
"#722ed1",
|
|
"#eb2f96",
|
|
"#13c2c2",
|
|
"#2f54eb",
|
|
"#f56a00"
|
|
];
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
internal 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);
|
|
}
|
|
|
|
internal 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;
|
|
}
|
|
|
|
internal static string ResolveAvatarColor(string? seed)
|
|
{
|
|
var source = string.IsNullOrWhiteSpace(seed) ? "member" : seed;
|
|
var hash = 0;
|
|
foreach (var ch in source)
|
|
{
|
|
hash = (hash * 31 + ch) & int.MaxValue;
|
|
}
|
|
|
|
return AvatarColors[hash % AvatarColors.Length];
|
|
}
|
|
|
|
internal static string ResolveAvatarText(string? name, string fallbackPhone)
|
|
{
|
|
var normalizedName = (name ?? string.Empty).Trim();
|
|
if (!string.IsNullOrWhiteSpace(normalizedName))
|
|
{
|
|
return normalizedName[..1];
|
|
}
|
|
|
|
return fallbackPhone.Length > 0 ? fallbackPhone[..1] : "会";
|
|
}
|
|
|
|
internal static string ResolveDisplayName(MemberProfile profile, MemberOrderMetrics metrics)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(profile.Nickname))
|
|
{
|
|
return profile.Nickname.Trim();
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(metrics.LatestCustomerName))
|
|
{
|
|
return metrics.LatestCustomerName;
|
|
}
|
|
|
|
return profile.Mobile.Length >= 4 ? $"会员{profile.Mobile[^4..]}" : "会员";
|
|
}
|
|
|
|
internal static decimal ResolveDisplayAmount(Order order)
|
|
{
|
|
return order.PaidAmount > 0 ? order.PaidAmount : order.PayableAmount;
|
|
}
|
|
|
|
internal static string ResolveOrderStatusText(OrderStatus value)
|
|
{
|
|
return value switch
|
|
{
|
|
OrderStatus.PendingPayment => "待付款",
|
|
OrderStatus.AwaitingPreparation => "待接单",
|
|
OrderStatus.InProgress => "制作中",
|
|
OrderStatus.Ready => "待取餐",
|
|
OrderStatus.Completed => "已完成",
|
|
OrderStatus.Cancelled => "已取消",
|
|
_ => "未知"
|
|
};
|
|
}
|
|
|
|
internal static string NormalizeRuleType(string? value)
|
|
{
|
|
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
|
{
|
|
"amount" => "amount",
|
|
"count" => "count",
|
|
"both" => "both",
|
|
_ => "none"
|
|
};
|
|
}
|
|
|
|
internal static MemberTierBenefitsDto NormalizeBenefits(MemberTierBenefitsDto? source)
|
|
{
|
|
var model = source ?? new MemberTierBenefitsDto();
|
|
|
|
var discountRate = model.Discount.DiscountRate;
|
|
if (discountRate.HasValue)
|
|
{
|
|
discountRate = Math.Clamp(decimal.Round(discountRate.Value, 2, MidpointRounding.AwayFromZero), 0m, 10m);
|
|
}
|
|
|
|
var multiplier = model.PointMultiplier.Multiplier;
|
|
if (multiplier.HasValue)
|
|
{
|
|
multiplier = Math.Clamp(decimal.Round(multiplier.Value, 2, MidpointRounding.AwayFromZero), 0m, 10m);
|
|
}
|
|
|
|
var monthlyGrantDay = Math.Clamp(model.MonthlyCoupon.GrantDay <= 0 ? 1 : model.MonthlyCoupon.GrantDay, 1, 28);
|
|
var monthlyFreeTimes = Math.Max(0, model.FreeDelivery.MonthlyFreeTimes);
|
|
|
|
return new MemberTierBenefitsDto
|
|
{
|
|
Discount = new MemberTierDiscountBenefitDto
|
|
{
|
|
Enabled = model.Discount.Enabled,
|
|
DiscountRate = discountRate
|
|
},
|
|
PointMultiplier = new MemberTierPointMultiplierBenefitDto
|
|
{
|
|
Enabled = model.PointMultiplier.Enabled,
|
|
Multiplier = multiplier
|
|
},
|
|
Birthday = new MemberTierBirthdayBenefitDto
|
|
{
|
|
Enabled = model.Birthday.Enabled,
|
|
DoublePointsEnabled = model.Birthday.DoublePointsEnabled,
|
|
CouponTemplateIds = NormalizeCouponIds(model.Birthday.CouponTemplateIds)
|
|
},
|
|
MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto
|
|
{
|
|
Enabled = model.MonthlyCoupon.Enabled,
|
|
GrantDay = monthlyGrantDay,
|
|
CouponTemplateIds = NormalizeCouponIds(model.MonthlyCoupon.CouponTemplateIds)
|
|
},
|
|
FreeDelivery = new MemberTierFreeDeliveryBenefitDto
|
|
{
|
|
Enabled = model.FreeDelivery.Enabled,
|
|
MonthlyFreeTimes = monthlyFreeTimes
|
|
},
|
|
PriorityDeliveryEnabled = model.PriorityDeliveryEnabled,
|
|
ExclusiveServiceEnabled = model.ExclusiveServiceEnabled
|
|
};
|
|
}
|
|
|
|
internal static MemberTierBenefitsDto DeserializeBenefits(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return new MemberTierBenefitsDto();
|
|
}
|
|
|
|
try
|
|
{
|
|
var model = JsonSerializer.Deserialize<MemberTierBenefitsDto>(value, JsonOptions);
|
|
return NormalizeBenefits(model);
|
|
}
|
|
catch
|
|
{
|
|
return new MemberTierBenefitsDto();
|
|
}
|
|
}
|
|
|
|
internal static string SerializeBenefits(MemberTierBenefitsDto benefits)
|
|
{
|
|
return JsonSerializer.Serialize(NormalizeBenefits(benefits), JsonOptions);
|
|
}
|
|
|
|
internal static string BuildConditionText(MemberTier tier)
|
|
{
|
|
var ruleType = NormalizeRuleType(tier.UpgradeRuleType);
|
|
return ruleType switch
|
|
{
|
|
"amount" => $"累计消费满 {FormatAmount(tier.UpgradeAmountThreshold)}",
|
|
"count" => $"累计消费满 {Math.Max(0, tier.UpgradeOrderCountThreshold ?? 0)} 次",
|
|
"both" => $"累计消费满 {FormatAmount(tier.UpgradeAmountThreshold)} 且累计消费满 {Math.Max(0, tier.UpgradeOrderCountThreshold ?? 0)} 次",
|
|
_ => "注册即享"
|
|
};
|
|
}
|
|
|
|
internal static IReadOnlyList<string> BuildPerks(MemberTier tier, MemberTierBenefitsDto benefits)
|
|
{
|
|
var result = new List<string>();
|
|
|
|
if (benefits.Discount.Enabled && benefits.Discount.DiscountRate.HasValue)
|
|
{
|
|
result.Add($"全场{benefits.Discount.DiscountRate.Value:0.##}折");
|
|
}
|
|
|
|
if (benefits.Birthday.Enabled)
|
|
{
|
|
if (benefits.Birthday.DoublePointsEnabled && benefits.Birthday.CouponTemplateIds.Count > 0)
|
|
{
|
|
result.Add("生日赠券+双倍积分");
|
|
}
|
|
else if (benefits.Birthday.DoublePointsEnabled)
|
|
{
|
|
result.Add("生日双倍积分");
|
|
}
|
|
else if (benefits.Birthday.CouponTemplateIds.Count > 0)
|
|
{
|
|
result.Add("生日赠券");
|
|
}
|
|
else
|
|
{
|
|
result.Add("生日特权");
|
|
}
|
|
}
|
|
|
|
if (benefits.MonthlyCoupon.Enabled)
|
|
{
|
|
result.Add("每月赠券");
|
|
}
|
|
|
|
if (benefits.PointMultiplier.Enabled && benefits.PointMultiplier.Multiplier.HasValue && benefits.PointMultiplier.Multiplier.Value > 1)
|
|
{
|
|
result.Add($"积分{benefits.PointMultiplier.Multiplier.Value:0.##}倍");
|
|
}
|
|
|
|
if (benefits.FreeDelivery.Enabled)
|
|
{
|
|
result.Add("免配送费");
|
|
}
|
|
|
|
if (benefits.PriorityDeliveryEnabled)
|
|
{
|
|
result.Add("优先配送");
|
|
}
|
|
|
|
if (benefits.ExclusiveServiceEnabled)
|
|
{
|
|
result.Add("专属客服");
|
|
}
|
|
|
|
if (result.Count == 0 && tier.IsDefault)
|
|
{
|
|
return ["积分累计", "会员价商品"];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
internal static async Task EnsureMemberCenterInitializedAsync(
|
|
IMemberRepository memberRepository,
|
|
IOrderRepository orderRepository,
|
|
ITenantProvider tenantProvider,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
|
if (tenantId <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
|
var changed = false;
|
|
|
|
if (tiers.Count == 0)
|
|
{
|
|
foreach (var seed in BuildDefaultTierSeeds())
|
|
{
|
|
await memberRepository.AddTierAsync(seed, cancellationToken);
|
|
}
|
|
|
|
changed = true;
|
|
}
|
|
|
|
var memberDaySetting = await memberRepository.GetMemberDaySettingAsync(tenantId, cancellationToken);
|
|
if (memberDaySetting is null)
|
|
{
|
|
await memberRepository.AddMemberDaySettingAsync(new MemberDaySetting
|
|
{
|
|
IsEnabled = true,
|
|
Weekday = 2,
|
|
ExtraDiscountRate = 9m
|
|
}, cancellationToken);
|
|
changed = true;
|
|
}
|
|
|
|
if (changed)
|
|
{
|
|
await memberRepository.SaveChangesAsync(cancellationToken);
|
|
tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
|
}
|
|
|
|
var allOrders = await orderRepository.SearchAllOrdersAsync(
|
|
tenantId,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
cancellationToken);
|
|
|
|
var validOrders = allOrders
|
|
.Where(IsQualifiedOrder)
|
|
.Where(order => !string.IsNullOrWhiteSpace(NormalizePhone(order.CustomerPhone)))
|
|
.ToList();
|
|
|
|
if (validOrders.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var nowUtc = DateTime.UtcNow;
|
|
var orderMetricsMap = BuildOrderMetrics(validOrders, nowUtc);
|
|
var mobileKeys = orderMetricsMap.Keys.ToList();
|
|
var existedProfiles = await memberRepository.GetProfilesByMobilesAsync(tenantId, mobileKeys, cancellationToken);
|
|
var profileLookup = existedProfiles.ToDictionary(
|
|
item => NormalizePhone(item.Mobile),
|
|
item => item,
|
|
StringComparer.Ordinal);
|
|
|
|
var defaultTier = ResolveDefaultTier(tiers);
|
|
var profilesToAdd = new List<MemberProfile>();
|
|
|
|
foreach (var (mobile, metrics) in orderMetricsMap)
|
|
{
|
|
if (profileLookup.ContainsKey(mobile))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
profilesToAdd.Add(new MemberProfile
|
|
{
|
|
UserId = 0,
|
|
Mobile = mobile,
|
|
Nickname = string.IsNullOrWhiteSpace(metrics.LatestCustomerName) ? null : metrics.LatestCustomerName,
|
|
MemberTierId = defaultTier?.Id,
|
|
StoredBalance = 0,
|
|
StoredRechargeBalance = 0,
|
|
StoredGiftBalance = 0,
|
|
Status = MemberStatus.Active,
|
|
PointsBalance = 0,
|
|
GrowthValue = ToGrowthValue(metrics.TotalAmount),
|
|
JoinedAt = metrics.FirstOrderAt
|
|
});
|
|
}
|
|
|
|
if (profilesToAdd.Count > 0)
|
|
{
|
|
await memberRepository.AddProfilesAsync(profilesToAdd, cancellationToken);
|
|
await memberRepository.SaveChangesAsync(cancellationToken);
|
|
existedProfiles = await memberRepository.GetProfilesByMobilesAsync(tenantId, mobileKeys, cancellationToken);
|
|
profileLookup = existedProfiles.ToDictionary(
|
|
item => NormalizePhone(item.Mobile),
|
|
item => item,
|
|
StringComparer.Ordinal);
|
|
}
|
|
|
|
if (profileLookup.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var tierLookup = tiers.ToDictionary(item => item.Id);
|
|
var orderedTiers = tiers
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.Id)
|
|
.ToList();
|
|
|
|
var hasProfileChanged = false;
|
|
foreach (var (mobile, profile) in profileLookup)
|
|
{
|
|
if (!orderMetricsMap.TryGetValue(mobile, out var metrics))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var targetTier = ResolveTargetTier(profile, orderedTiers, tierLookup, metrics, nowUtc);
|
|
var targetJoinedAt = profile.JoinedAt == default
|
|
? metrics.FirstOrderAt
|
|
: (profile.JoinedAt > metrics.FirstOrderAt ? metrics.FirstOrderAt : profile.JoinedAt);
|
|
var targetGrowth = ToGrowthValue(metrics.TotalAmount);
|
|
var targetNickname = string.IsNullOrWhiteSpace(profile.Nickname)
|
|
? (string.IsNullOrWhiteSpace(metrics.LatestCustomerName) ? profile.Nickname : metrics.LatestCustomerName)
|
|
: profile.Nickname;
|
|
|
|
if (profile.MemberTierId != targetTier?.Id ||
|
|
profile.GrowthValue != targetGrowth ||
|
|
profile.JoinedAt != targetJoinedAt ||
|
|
profile.Nickname != targetNickname)
|
|
{
|
|
profile.MemberTierId = targetTier?.Id;
|
|
profile.GrowthValue = targetGrowth;
|
|
profile.JoinedAt = targetJoinedAt;
|
|
profile.Nickname = targetNickname;
|
|
await memberRepository.UpdateProfileAsync(profile, cancellationToken);
|
|
hasProfileChanged = true;
|
|
}
|
|
}
|
|
|
|
if (hasProfileChanged)
|
|
{
|
|
await memberRepository.SaveChangesAsync(cancellationToken);
|
|
}
|
|
}
|
|
|
|
internal static async Task<MemberContextSnapshot> LoadMemberContextAsync(
|
|
IMemberRepository memberRepository,
|
|
IOrderRepository orderRepository,
|
|
ITenantProvider tenantProvider,
|
|
IReadOnlyCollection<long> visibleStoreIds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await EnsureMemberCenterInitializedAsync(memberRepository, orderRepository, tenantProvider, cancellationToken);
|
|
|
|
if (visibleStoreIds.Count == 0)
|
|
{
|
|
return new MemberContextSnapshot();
|
|
}
|
|
|
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
|
var tiers = await memberRepository.GetTiersAsync(tenantId, cancellationToken);
|
|
var profiles = await memberRepository.GetProfilesAsync(tenantId, cancellationToken);
|
|
|
|
if (tiers.Count == 0 || profiles.Count == 0)
|
|
{
|
|
return new MemberContextSnapshot
|
|
{
|
|
TierLookup = tiers.ToDictionary(item => item.Id),
|
|
Aggregates = []
|
|
};
|
|
}
|
|
|
|
var orders = await orderRepository.SearchAllOrdersAsync(
|
|
tenantId,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
cancellationToken);
|
|
|
|
var visibleStoreSet = visibleStoreIds.ToHashSet();
|
|
var visibleOrders = orders
|
|
.Where(IsQualifiedOrder)
|
|
.Where(order => visibleStoreSet.Contains(order.StoreId))
|
|
.Where(order => !string.IsNullOrWhiteSpace(NormalizePhone(order.CustomerPhone)))
|
|
.ToList();
|
|
|
|
if (visibleOrders.Count == 0)
|
|
{
|
|
return new MemberContextSnapshot
|
|
{
|
|
TierLookup = tiers.ToDictionary(item => item.Id),
|
|
Aggregates = []
|
|
};
|
|
}
|
|
|
|
var metricsMap = BuildOrderMetrics(visibleOrders, DateTime.UtcNow);
|
|
var tierLookup = tiers.ToDictionary(item => item.Id);
|
|
var defaultTier = ResolveDefaultTier(tiers);
|
|
|
|
var aggregates = new List<MemberAggregate>();
|
|
foreach (var profile in profiles)
|
|
{
|
|
var mobile = NormalizePhone(profile.Mobile);
|
|
if (string.IsNullOrWhiteSpace(mobile) || !metricsMap.TryGetValue(mobile, out var metrics))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var tier = profile.MemberTierId.HasValue && tierLookup.TryGetValue(profile.MemberTierId.Value, out var matchedTier)
|
|
? matchedTier
|
|
: defaultTier;
|
|
var name = ResolveDisplayName(profile, metrics);
|
|
var avatarText = ResolveAvatarText(name, mobile);
|
|
var avatarColor = ResolveAvatarColor(mobile);
|
|
var joinedAt = profile.JoinedAt == default ? metrics.FirstOrderAt : profile.JoinedAt;
|
|
|
|
aggregates.Add(new MemberAggregate
|
|
{
|
|
MemberId = profile.Id,
|
|
Mobile = mobile,
|
|
MobileMasked = MaskPhone(mobile),
|
|
Name = name,
|
|
AvatarText = avatarText,
|
|
AvatarColor = avatarColor,
|
|
JoinedAt = joinedAt,
|
|
Tier = tier,
|
|
Profile = profile,
|
|
Metrics = metrics,
|
|
IsDormant = metrics.LastOrderAt < DateTime.UtcNow.AddDays(-60)
|
|
});
|
|
}
|
|
|
|
return new MemberContextSnapshot
|
|
{
|
|
TierLookup = tierLookup,
|
|
Aggregates = aggregates
|
|
.OrderByDescending(item => item.Metrics.LastOrderAt)
|
|
.ThenByDescending(item => item.MemberId)
|
|
.ToList()
|
|
};
|
|
}
|
|
|
|
internal static IReadOnlyList<MemberAggregate> ApplyFilters(
|
|
IReadOnlyList<MemberAggregate> source,
|
|
string? keyword,
|
|
long? tierId)
|
|
{
|
|
var normalizedKeyword = (keyword ?? string.Empty).Trim();
|
|
var keywordDigits = NormalizePhone(normalizedKeyword);
|
|
|
|
return source
|
|
.Where(item =>
|
|
{
|
|
if (tierId.HasValue)
|
|
{
|
|
if (!item.Tier?.Id.Equals(tierId.Value) ?? true)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(normalizedKeyword))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var matchedByName = item.Name.Contains(normalizedKeyword, StringComparison.OrdinalIgnoreCase);
|
|
var matchedByMobile = !string.IsNullOrWhiteSpace(keywordDigits) &&
|
|
item.Mobile.Contains(keywordDigits, StringComparison.Ordinal);
|
|
return matchedByName || matchedByMobile;
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
internal static string BuildCsv(IReadOnlyList<MemberListItemDto> items)
|
|
{
|
|
var builder = new StringBuilder();
|
|
builder.AppendLine("会员,手机号,等级,累计消费,消费次数,最近消费,储值余额,积分");
|
|
|
|
foreach (var item in items)
|
|
{
|
|
builder.Append(EscapeCsv(item.Name));
|
|
builder.Append(',');
|
|
builder.Append(EscapeCsv(item.MobileMasked));
|
|
builder.Append(',');
|
|
builder.Append(EscapeCsv(item.TierName));
|
|
builder.Append(',');
|
|
builder.Append(item.TotalAmount.ToString("0.00", CultureInfo.InvariantCulture));
|
|
builder.Append(',');
|
|
builder.Append(item.OrderCount.ToString(CultureInfo.InvariantCulture));
|
|
builder.Append(',');
|
|
builder.Append(item.LastOrderAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
|
builder.Append(',');
|
|
builder.Append(item.StoredBalance.ToString("0.00", CultureInfo.InvariantCulture));
|
|
builder.Append(',');
|
|
builder.Append(item.PointsBalance.ToString(CultureInfo.InvariantCulture));
|
|
builder.AppendLine();
|
|
}
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
internal static bool IsCouponVisibleToStores(CouponTemplate template, IReadOnlyCollection<long> visibleStoreIds)
|
|
{
|
|
if (visibleStoreIds.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var visibleStoreSet = visibleStoreIds.ToHashSet();
|
|
|
|
if (string.IsNullOrWhiteSpace(template.StoreScopeJson))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(template.StoreScopeJson);
|
|
if (!document.RootElement.TryGetProperty("mode", out var modeElement))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var mode = modeElement.GetString()?.Trim().ToLowerInvariant() ?? "stores";
|
|
if (mode == "all")
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!document.RootElement.TryGetProperty("storeIds", out var idsElement) || idsElement.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (var item in idsElement.EnumerateArray())
|
|
{
|
|
if (item.TryGetInt64(out var storeId) && visibleStoreSet.Contains(storeId))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
internal static bool IsCouponActive(CouponTemplate template, DateTime nowUtc)
|
|
{
|
|
if (template.Status != CouponTemplateStatus.Active)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (template.ValidFrom.HasValue && template.ValidFrom.Value > nowUtc)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (template.ValidTo.HasValue && template.ValidTo.Value < nowUtc)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
internal static string ResolveCouponTypeText(CouponType couponType)
|
|
{
|
|
return couponType switch
|
|
{
|
|
CouponType.AmountOff => "amount_off",
|
|
CouponType.Percentage => "discount",
|
|
CouponType.DeliveryFee => "free_delivery",
|
|
CouponType.Cash => "cash",
|
|
CouponType.Gift => "gift",
|
|
_ => "amount_off"
|
|
};
|
|
}
|
|
|
|
internal static string BuildCouponDisplayText(CouponTemplate template)
|
|
{
|
|
return template.CouponType switch
|
|
{
|
|
CouponType.AmountOff => template.MinimumSpend.HasValue && template.MinimumSpend.Value > 0
|
|
? $"满{template.MinimumSpend.Value:0.##}减{template.Value:0.##}"
|
|
: $"减{template.Value:0.##}",
|
|
CouponType.Percentage => template.MinimumSpend.HasValue && template.MinimumSpend.Value > 0
|
|
? $"满{template.MinimumSpend.Value:0.##}享{template.Value:0.##}折"
|
|
: $"{template.Value:0.##}折",
|
|
CouponType.DeliveryFee => "免配送费",
|
|
CouponType.Cash => $"现金券 {template.Value:0.##}",
|
|
CouponType.Gift => "礼品券",
|
|
_ => template.Name
|
|
};
|
|
}
|
|
|
|
internal static MemberTierDetailDto ToTierDetailDto(MemberTier tier, bool canDelete)
|
|
{
|
|
var benefits = DeserializeBenefits(tier.BenefitsJson);
|
|
return new MemberTierDetailDto
|
|
{
|
|
TierId = tier.Id,
|
|
SortOrder = tier.SortOrder,
|
|
Name = tier.Name,
|
|
IconKey = tier.IconKey,
|
|
ColorHex = tier.ColorHex,
|
|
IsDefault = tier.IsDefault,
|
|
Rule = new MemberTierRuleDto
|
|
{
|
|
UpgradeRuleType = NormalizeRuleType(tier.UpgradeRuleType),
|
|
UpgradeAmountThreshold = tier.UpgradeAmountThreshold,
|
|
UpgradeOrderCountThreshold = tier.UpgradeOrderCountThreshold,
|
|
DowngradeWindowDays = tier.DowngradeWindowDays
|
|
},
|
|
Benefits = benefits,
|
|
CanDelete = canDelete
|
|
};
|
|
}
|
|
|
|
internal static MemberDaySettingDto ToMemberDaySettingDto(MemberDaySetting setting)
|
|
{
|
|
return new MemberDaySettingDto
|
|
{
|
|
IsEnabled = setting.IsEnabled,
|
|
Weekday = setting.Weekday,
|
|
ExtraDiscountRate = setting.ExtraDiscountRate
|
|
};
|
|
}
|
|
|
|
private static string EscapeCsv(string? value)
|
|
{
|
|
var source = value ?? string.Empty;
|
|
if (!source.Contains(',') && !source.Contains('"') && !source.Contains('\n'))
|
|
{
|
|
return source;
|
|
}
|
|
|
|
return $"\"{source.Replace("\"", "\"\"")}\"";
|
|
}
|
|
|
|
private static IReadOnlyList<long> NormalizeCouponIds(IReadOnlyList<long>? source)
|
|
{
|
|
return (source ?? [])
|
|
.Where(item => item > 0)
|
|
.Distinct()
|
|
.OrderBy(item => item)
|
|
.ToList();
|
|
}
|
|
|
|
private static string FormatAmount(decimal? value)
|
|
{
|
|
var amount = Math.Max(0m, value ?? 0m);
|
|
return $"¥{amount:0.##}";
|
|
}
|
|
|
|
private static bool IsQualifiedOrder(Order order)
|
|
{
|
|
if (order.Status == OrderStatus.Cancelled)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (order.PaymentStatus is PaymentStatus.Unpaid or PaymentStatus.Paying or PaymentStatus.Failed)
|
|
{
|
|
return order.PaidAmount > 0;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static int ToGrowthValue(decimal totalAmount)
|
|
{
|
|
var value = decimal.Round(totalAmount, 0, MidpointRounding.AwayFromZero);
|
|
if (value > int.MaxValue)
|
|
{
|
|
return int.MaxValue;
|
|
}
|
|
|
|
if (value < 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
return (int)value;
|
|
}
|
|
|
|
private static MemberTier? ResolveDefaultTier(IReadOnlyList<MemberTier> tiers)
|
|
{
|
|
return tiers
|
|
.OrderByDescending(item => item.IsDefault)
|
|
.ThenBy(item => item.SortOrder)
|
|
.ThenBy(item => item.Id)
|
|
.FirstOrDefault();
|
|
}
|
|
|
|
private static MemberTier? ResolveTargetTier(
|
|
MemberProfile profile,
|
|
IReadOnlyList<MemberTier> orderedTiers,
|
|
IReadOnlyDictionary<long, MemberTier> tierLookup,
|
|
MemberOrderMetrics metrics,
|
|
DateTime nowUtc)
|
|
{
|
|
if (orderedTiers.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var defaultTier = ResolveDefaultTier(orderedTiers) ?? orderedTiers[0];
|
|
var candidateTier = ResolveTierByMetrics(orderedTiers, metrics.TotalAmount, metrics.TotalOrderCount) ?? defaultTier;
|
|
|
|
if (!profile.MemberTierId.HasValue || !tierLookup.TryGetValue(profile.MemberTierId.Value, out var currentTier))
|
|
{
|
|
return candidateTier;
|
|
}
|
|
|
|
if (candidateTier.SortOrder > currentTier.SortOrder)
|
|
{
|
|
return candidateTier;
|
|
}
|
|
|
|
var downgradeWindowDays = currentTier.DowngradeWindowDays > 0 ? currentTier.DowngradeWindowDays : 90;
|
|
var windowStart = nowUtc.AddDays(-downgradeWindowDays);
|
|
var windowOrders = metrics.Orders
|
|
.Where(item => item.OrderedAt >= windowStart)
|
|
.ToList();
|
|
|
|
var windowAmount = windowOrders.Sum(item => item.Amount);
|
|
var windowOrderCount = windowOrders.Count;
|
|
|
|
if (MatchTierRule(currentTier, windowAmount, windowOrderCount))
|
|
{
|
|
return currentTier;
|
|
}
|
|
|
|
var previousTier = orderedTiers
|
|
.Where(item => item.SortOrder < currentTier.SortOrder)
|
|
.OrderByDescending(item => item.SortOrder)
|
|
.ThenByDescending(item => item.Id)
|
|
.FirstOrDefault();
|
|
|
|
if (previousTier is null)
|
|
{
|
|
return defaultTier;
|
|
}
|
|
|
|
return previousTier;
|
|
}
|
|
|
|
private static MemberTier? ResolveTierByMetrics(
|
|
IReadOnlyList<MemberTier> orderedTiers,
|
|
decimal totalAmount,
|
|
int totalOrderCount)
|
|
{
|
|
MemberTier? matched = null;
|
|
foreach (var tier in orderedTiers)
|
|
{
|
|
if (MatchTierRule(tier, totalAmount, totalOrderCount))
|
|
{
|
|
matched = tier;
|
|
}
|
|
}
|
|
|
|
return matched;
|
|
}
|
|
|
|
private static bool MatchTierRule(MemberTier tier, decimal amount, int orderCount)
|
|
{
|
|
var ruleType = NormalizeRuleType(tier.UpgradeRuleType);
|
|
var thresholdAmount = Math.Max(0m, tier.UpgradeAmountThreshold ?? 0m);
|
|
var thresholdCount = Math.Max(0, tier.UpgradeOrderCountThreshold ?? 0);
|
|
|
|
return ruleType switch
|
|
{
|
|
"amount" => amount >= thresholdAmount,
|
|
"count" => orderCount >= thresholdCount,
|
|
"both" => amount >= thresholdAmount && orderCount >= thresholdCount,
|
|
_ => true
|
|
};
|
|
}
|
|
|
|
private static Dictionary<string, MemberOrderMetrics> BuildOrderMetrics(
|
|
IReadOnlyList<Order> orders,
|
|
DateTime nowUtc)
|
|
{
|
|
return orders
|
|
.GroupBy(item => NormalizePhone(item.CustomerPhone), StringComparer.Ordinal)
|
|
.Where(group => !string.IsNullOrWhiteSpace(group.Key))
|
|
.ToDictionary(
|
|
group => group.Key,
|
|
group =>
|
|
{
|
|
var sorted = group
|
|
.OrderByDescending(item => item.CreatedAt)
|
|
.ThenByDescending(item => item.Id)
|
|
.ToList();
|
|
|
|
var totalAmount = sorted.Sum(ResolveDisplayAmount);
|
|
var totalOrderCount = sorted.Count;
|
|
var latestCustomerName = sorted
|
|
.Select(item => (item.CustomerName ?? string.Empty).Trim())
|
|
.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item)) ?? string.Empty;
|
|
|
|
var snapshots = sorted
|
|
.Select(item => new MemberOrderSnapshot
|
|
{
|
|
OrderId = item.Id,
|
|
OrderNo = item.OrderNo,
|
|
OrderedAt = item.CreatedAt,
|
|
Amount = ResolveDisplayAmount(item),
|
|
Status = item.Status
|
|
})
|
|
.ToList();
|
|
|
|
return new MemberOrderMetrics
|
|
{
|
|
Mobile = group.Key,
|
|
TotalAmount = totalAmount,
|
|
TotalOrderCount = totalOrderCount,
|
|
FirstOrderAt = sorted.Min(item => item.CreatedAt),
|
|
LastOrderAt = sorted.Max(item => item.CreatedAt),
|
|
LatestCustomerName = latestCustomerName,
|
|
Orders = snapshots
|
|
};
|
|
},
|
|
StringComparer.Ordinal);
|
|
}
|
|
|
|
private static IReadOnlyList<MemberTier> BuildDefaultTierSeeds()
|
|
{
|
|
var defaults = new List<MemberTier>
|
|
{
|
|
new()
|
|
{
|
|
Name = "普通会员",
|
|
RequiredGrowth = 0,
|
|
IconKey = "user",
|
|
ColorHex = "#999999",
|
|
UpgradeRuleType = "none",
|
|
UpgradeAmountThreshold = null,
|
|
UpgradeOrderCountThreshold = null,
|
|
DowngradeWindowDays = 90,
|
|
IsDefault = true,
|
|
SortOrder = 1,
|
|
BenefitsJson = SerializeBenefits(new MemberTierBenefitsDto())
|
|
},
|
|
new()
|
|
{
|
|
Name = "银卡会员",
|
|
RequiredGrowth = 500,
|
|
IconKey = "award",
|
|
ColorHex = "#1890ff",
|
|
UpgradeRuleType = "amount",
|
|
UpgradeAmountThreshold = 500m,
|
|
UpgradeOrderCountThreshold = null,
|
|
DowngradeWindowDays = 90,
|
|
IsDefault = false,
|
|
SortOrder = 2,
|
|
BenefitsJson = SerializeBenefits(new MemberTierBenefitsDto
|
|
{
|
|
Discount = new MemberTierDiscountBenefitDto
|
|
{
|
|
Enabled = true,
|
|
DiscountRate = 9.8m
|
|
},
|
|
PointMultiplier = new MemberTierPointMultiplierBenefitDto
|
|
{
|
|
Enabled = true,
|
|
Multiplier = 1.2m
|
|
},
|
|
Birthday = new MemberTierBirthdayBenefitDto
|
|
{
|
|
Enabled = true,
|
|
DoublePointsEnabled = false,
|
|
CouponTemplateIds = []
|
|
}
|
|
})
|
|
},
|
|
new()
|
|
{
|
|
Name = "金卡会员",
|
|
RequiredGrowth = 2000,
|
|
IconKey = "trophy",
|
|
ColorHex = "#fa8c16",
|
|
UpgradeRuleType = "amount",
|
|
UpgradeAmountThreshold = 2000m,
|
|
UpgradeOrderCountThreshold = null,
|
|
DowngradeWindowDays = 90,
|
|
IsDefault = false,
|
|
SortOrder = 3,
|
|
BenefitsJson = SerializeBenefits(new MemberTierBenefitsDto
|
|
{
|
|
Discount = new MemberTierDiscountBenefitDto
|
|
{
|
|
Enabled = true,
|
|
DiscountRate = 9.5m
|
|
},
|
|
PointMultiplier = new MemberTierPointMultiplierBenefitDto
|
|
{
|
|
Enabled = true,
|
|
Multiplier = 1.5m
|
|
},
|
|
Birthday = new MemberTierBirthdayBenefitDto
|
|
{
|
|
Enabled = true,
|
|
DoublePointsEnabled = true,
|
|
CouponTemplateIds = []
|
|
},
|
|
MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto
|
|
{
|
|
Enabled = true,
|
|
GrantDay = 1,
|
|
CouponTemplateIds = []
|
|
}
|
|
})
|
|
},
|
|
new()
|
|
{
|
|
Name = "钻石会员",
|
|
RequiredGrowth = 5000,
|
|
IconKey = "gem",
|
|
ColorHex = "#722ed1",
|
|
UpgradeRuleType = "amount",
|
|
UpgradeAmountThreshold = 5000m,
|
|
UpgradeOrderCountThreshold = null,
|
|
DowngradeWindowDays = 90,
|
|
IsDefault = false,
|
|
SortOrder = 4,
|
|
BenefitsJson = SerializeBenefits(new MemberTierBenefitsDto
|
|
{
|
|
Discount = new MemberTierDiscountBenefitDto
|
|
{
|
|
Enabled = true,
|
|
DiscountRate = 9m
|
|
},
|
|
PointMultiplier = new MemberTierPointMultiplierBenefitDto
|
|
{
|
|
Enabled = true,
|
|
Multiplier = 2m
|
|
},
|
|
Birthday = new MemberTierBirthdayBenefitDto
|
|
{
|
|
Enabled = true,
|
|
DoublePointsEnabled = true,
|
|
CouponTemplateIds = []
|
|
},
|
|
MonthlyCoupon = new MemberTierMonthlyCouponBenefitDto
|
|
{
|
|
Enabled = true,
|
|
GrantDay = 1,
|
|
CouponTemplateIds = []
|
|
},
|
|
FreeDelivery = new MemberTierFreeDeliveryBenefitDto
|
|
{
|
|
Enabled = true,
|
|
MonthlyFreeTimes = 5
|
|
},
|
|
PriorityDeliveryEnabled = true,
|
|
ExclusiveServiceEnabled = true
|
|
})
|
|
}
|
|
};
|
|
|
|
return defaults;
|
|
}
|
|
}
|
|
|
|
internal sealed class MemberContextSnapshot
|
|
{
|
|
internal IReadOnlyList<MemberAggregate> Aggregates { get; init; } = [];
|
|
internal IReadOnlyDictionary<long, MemberTier> TierLookup { get; init; } = new Dictionary<long, MemberTier>();
|
|
}
|
|
|
|
internal sealed class MemberAggregate
|
|
{
|
|
internal string AvatarColor { get; init; } = string.Empty;
|
|
internal string AvatarText { get; init; } = string.Empty;
|
|
internal long MemberId { get; init; }
|
|
internal MemberOrderMetrics Metrics { get; init; } = new();
|
|
internal string Mobile { get; init; } = string.Empty;
|
|
internal string MobileMasked { get; init; } = string.Empty;
|
|
internal string Name { get; init; } = string.Empty;
|
|
internal MemberProfile Profile { get; init; } = new();
|
|
internal MemberTier? Tier { get; init; }
|
|
internal DateTime JoinedAt { get; init; }
|
|
internal bool IsDormant { get; init; }
|
|
}
|
|
|
|
internal sealed class MemberOrderMetrics
|
|
{
|
|
internal DateTime FirstOrderAt { get; init; }
|
|
internal DateTime LastOrderAt { get; init; }
|
|
internal string LatestCustomerName { get; init; } = string.Empty;
|
|
internal string Mobile { get; init; } = string.Empty;
|
|
internal IReadOnlyList<MemberOrderSnapshot> Orders { get; init; } = [];
|
|
internal decimal TotalAmount { get; init; }
|
|
internal int TotalOrderCount { get; init; }
|
|
}
|
|
|
|
internal sealed class MemberOrderSnapshot
|
|
{
|
|
internal decimal Amount { get; init; }
|
|
internal long OrderId { get; init; }
|
|
internal string OrderNo { get; init; } = string.Empty;
|
|
internal DateTime OrderedAt { get; init; }
|
|
internal OrderStatus Status { get; init; }
|
|
}
|