using System.Globalization;
using System.Text.RegularExpressions;
using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Application.Shared;
///
/// 地理分区规则(价格/黑名单/偏远共用)。
///
public sealed class GeoRule
{
public int Ordinal { get; init; }
public object? Tag { get; init; }
public string CountryCode { get; init; } = string.Empty;
/// zip / state / city / country
public string MatchDim { get; init; } = string.Empty;
/// prefix / exact / range / all
public string MatchMode { get; init; } = string.Empty;
public int PrefixLen { get; init; }
public string MatchTextStd { get; init; } = string.Empty;
public string ZipStartStd { get; init; } = string.Empty;
public string ZipEndStd { get; init; } = string.Empty;
public string MatchStateStd { get; init; } = string.Empty;
}
///
/// 地址清洗、规则编译、单套分区匹配。价格/黑名单/偏远分区共用。
///
public static class GeoPartitionMatcher
{
public const string DimZip = "zip";
public const string DimState = "state";
public const string DimCity = "city";
public const string DimCountry = "country";
public const string ModePrefix = "prefix";
public const string ModeExact = "exact";
public const string ModeRange = "range";
public const string ModeAll = "all";
private static readonly Regex PostcodeRangePattern = new(
@"^[\[(]\s*([^,)\]]*)\s*,\s*([^,)\]]*)\s*[\])]$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static string NormalizeCountry(string? value) =>
string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
public static string NormalizeZip(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var chars = value.Where(ch => !char.IsWhiteSpace(ch) && ch != '-').ToArray();
return chars.Length == 0 ? string.Empty : new string(chars).ToUpperInvariant();
}
///
/// 规范化后的邮编比较:纯数字按等长补零;相同字母前缀+数字后缀按数值;其余按 Ordinal。
///
public static int CompareNormalizedZip(string? left, string? right)
{
var a = left ?? string.Empty;
var b = right ?? string.Empty;
if (a.Length == 0 && b.Length == 0)
{
return 0;
}
if (IsDigits(a) && IsDigits(b))
{
var width = Math.Max(a.Length, b.Length);
return string.CompareOrdinal(a.PadLeft(width, '0'), b.PadLeft(width, '0'));
}
if (TrySplitLetterPrefixNumber(a, out var prefixA, out var numberA, out var restA)
&& TrySplitLetterPrefixNumber(b, out var prefixB, out var numberB, out var restB)
&& string.Equals(prefixA, prefixB, StringComparison.Ordinal))
{
var numberCmp = numberA.CompareTo(numberB);
return numberCmp != 0 ? numberCmp : string.CompareOrdinal(restA, restB);
}
return string.CompareOrdinal(a, b);
}
private static bool IsDigits(string value) =>
value.Length > 0 && value.All(char.IsAsciiDigit);
private static bool TrySplitLetterPrefixNumber(
string zip,
out string prefix,
out int number,
out string rest)
{
prefix = string.Empty;
number = 0;
rest = string.Empty;
if (zip.Length == 0 || !char.IsAsciiLetter(zip[0]))
{
return false;
}
var i = 0;
while (i < zip.Length && char.IsAsciiLetter(zip[i]))
{
i++;
}
if (i >= zip.Length || !char.IsAsciiDigit(zip[i]))
{
return false;
}
prefix = zip[..i];
var digitStart = i;
while (i < zip.Length && char.IsAsciiDigit(zip[i]))
{
i++;
}
if (!int.TryParse(zip[digitStart..i], NumberStyles.None, CultureInfo.InvariantCulture, out number))
{
return false;
}
rest = zip[i..];
return true;
}
public static string NormalizeRegion(string? value) =>
string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
///
/// 执行单套地理分区匹配。调用方需传入已排序规则:zip > state > city > country,同维度 prefixLen 降序。
///
public static GeoRule? MatchSinglePartition(
string countryCode,
string zipStd,
string stateStd,
string cityStd,
IReadOnlyList ruleList)
{
var country = NormalizeCountry(countryCode);
zipStd ??= string.Empty;
stateStd ??= string.Empty;
cityStd ??= string.Empty;
if (ruleList == null || ruleList.Count == 0 || string.IsNullOrEmpty(country))
{
return null;
}
foreach (var rule in ruleList)
{
if (!string.Equals(rule.CountryCode, country, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (rule.MatchDim == DimZip)
{
if (string.IsNullOrEmpty(zipStd))
{
continue;
}
var hit = false;
if (rule.MatchMode == ModePrefix)
{
if (rule.PrefixLen > 0 && zipStd.Length >= rule.PrefixLen)
{
var userPrefix = zipStd[..rule.PrefixLen];
hit = userPrefix == rule.MatchTextStd;
}
}
else if (rule.MatchMode == ModeExact)
{
hit = zipStd == rule.MatchTextStd;
}
else if (rule.MatchMode == ModeRange)
{
hit = CompareNormalizedZip(zipStd, rule.ZipStartStd) >= 0
&& CompareNormalizedZip(zipStd, rule.ZipEndStd) <= 0;
}
if (hit)
{
return rule;
}
}
else if (rule.MatchDim == DimState)
{
if (!string.IsNullOrEmpty(zipStd))
{
continue;
}
if (rule.MatchMode == ModeExact && stateStd == rule.MatchTextStd && !string.IsNullOrEmpty(stateStd))
{
return rule;
}
}
else if (rule.MatchDim == DimCity)
{
if (!string.IsNullOrEmpty(zipStd))
{
continue;
}
if (rule.MatchMode == ModeExact
&& rule.MatchStateStd == stateStd
&& rule.MatchTextStd == cityStd
&& !string.IsNullOrEmpty(stateStd)
&& !string.IsNullOrEmpty(cityStd))
{
return rule;
}
}
else if (rule.MatchDim == DimCountry)
{
return rule;
}
}
return null;
}
public static IReadOnlyList SortRules(IEnumerable rules) =>
rules
.OrderBy(DimRank)
.ThenByDescending(r => r.PrefixLen)
.ThenByDescending(RangeSpecificity)
.ThenBy(r => r.Ordinal)
.ToList();
public static GeoRule? Compile(KpiAreaInfo entity, int ordinal = 0, bool attachTag = true)
{
if (entity == null)
{
return null;
}
return Compile(
entity.CountryCode,
entity.Province,
entity.City,
entity.LeftPostcode,
entity.RightPostcode,
null,
entity.MatchDim,
entity.MatchMode,
entity.PrefixLen,
ordinal,
attachTag ? entity : null);
}
public static GeoRule? Compile(KpiAreaSnapshotRuleDto rule, int ordinal = 0, bool attachTag = true)
{
if (rule == null)
{
return null;
}
var range = KpiAreaSnapshotMapper.ResolvePostcodeRange(rule);
return Compile(
rule.CountryCode,
rule.Province,
rule.City,
rule.LeftPostcode,
rule.RightPostcode,
range,
rule.MatchDim,
rule.MatchMode,
rule.PrefixLen,
ordinal,
attachTag ? rule : null);
}
public static KpiAreaSnapshotRuleDto? MatchSnapshot(
IEnumerable? infos,
string countryCode,
string? province,
string? city,
string? zipCode)
{
var hit = Match(CompileList(infos, Compile), countryCode, province, city, zipCode);
return hit?.Tag as KpiAreaSnapshotRuleDto;
}
public static KpiAreaInfo? MatchEntity(
IEnumerable? infos,
string countryCode,
string? province,
string? city,
string? zipCode)
{
var hit = Match(CompileList(infos, Compile), countryCode, province, city, zipCode);
return hit?.Tag as KpiAreaInfo;
}
public static bool IsHit(
IEnumerable? infos,
string countryCode,
string? province,
string? city,
string? zipCode) =>
MatchEntity(infos, countryCode, province, city, zipCode) != null;
public static bool IsHit(
IEnumerable? infos,
string countryCode,
string? province,
string? city,
string? zipCode) =>
MatchSnapshot(infos, countryCode, province, city, zipCode) != null;
public static GeoRule? Match(
IReadOnlyList compiled,
string countryCode,
string? province,
string? city,
string? zipCode)
{
var sorted = SortRules(compiled);
return MatchSinglePartition(
NormalizeCountry(countryCode),
NormalizeZip(zipCode),
NormalizeRegion(province),
NormalizeRegion(city),
sorted);
}
///
/// 清洗并收口为一维规则,写入 MatchDim / MatchMode / PrefixLen。
/// 显式维度优先;未传则按字段推断。
///
public static void PrepareEntity(KpiAreaInfo entity)
{
ArgumentNullException.ThrowIfNull(entity);
entity.CountryCode = string.IsNullOrWhiteSpace(entity.CountryCode) ? null : entity.CountryCode.Trim();
entity.CountryName = string.IsNullOrWhiteSpace(entity.CountryName) ? null : entity.CountryName.Trim();
entity.Province = EmptyToNull(entity.Province);
entity.City = EmptyToNull(entity.City);
entity.LeftPostcode = EmptyToNull(entity.LeftPostcode);
entity.RightPostcode = EmptyToNull(entity.RightPostcode);
if (string.IsNullOrWhiteSpace(entity.CountryCode))
{
throw new InvalidOperationException("国家代码不能为空");
}
var dim = NormalizeDim(entity.MatchDim);
var mode = NormalizeMode(entity.MatchMode);
if (string.IsNullOrEmpty(dim))
{
dim = InferDim(entity.LeftPostcode, entity.RightPostcode, entity.Province, entity.City);
}
switch (dim)
{
case DimZip:
ApplyZipFields(entity, mode);
break;
case DimState:
if (string.IsNullOrWhiteSpace(entity.Province))
{
throw new InvalidOperationException("州/省匹配必须填写州/省");
}
entity.City = null;
entity.LeftPostcode = null;
entity.RightPostcode = null;
entity.MatchDim = DimState;
entity.MatchMode = ModeExact;
entity.PrefixLen = 0;
break;
case DimCity:
if (string.IsNullOrWhiteSpace(entity.Province) || string.IsNullOrWhiteSpace(entity.City))
{
throw new InvalidOperationException("城市匹配必须同时填写州/省");
}
entity.LeftPostcode = null;
entity.RightPostcode = null;
entity.MatchDim = DimCity;
entity.MatchMode = ModeExact;
entity.PrefixLen = 0;
break;
default:
entity.Province = null;
entity.City = null;
entity.LeftPostcode = null;
entity.RightPostcode = null;
entity.MatchDim = DimCountry;
entity.MatchMode = ModeAll;
entity.PrefixLen = 0;
break;
}
}
///
/// 同一分区内判定完全相同规则的指纹(不比较 AreaId)。
/// 须在 PrepareEntity 之后调用。
///
public static string BuildDuplicateKey(KpiAreaInfo entity)
{
ArgumentNullException.ThrowIfNull(entity);
var dim = NormalizeDim(entity.MatchDim) ?? DimCountry;
var mode = NormalizeMode(entity.MatchMode) ?? ModeAll;
var country = NormKey(entity.CountryCode);
return dim switch
{
DimState => $"{DimState}|{country}|{NormKey(entity.Province)}",
DimCity => $"{DimCity}|{country}|{NormKey(entity.Province)}|{NormKey(entity.City)}",
DimZip when mode == ModePrefix =>
$"{DimZip}|{ModePrefix}|{country}|{NormKey(entity.LeftPostcode)}|{entity.PrefixLen}",
DimZip when mode == ModeExact =>
$"{DimZip}|{ModeExact}|{country}|{NormKey(entity.LeftPostcode)}",
DimZip =>
$"{DimZip}|{ModeRange}|{country}|{NormKey(entity.LeftPostcode)}|{NormKey(entity.RightPostcode)}",
_ => $"{DimCountry}|{country}"
};
}
private static string NormKey(string? value) => (value ?? string.Empty).Trim().ToUpperInvariant();
public static (string MatchDim, string MatchMode, int PrefixLen) Describe(KpiAreaInfo? entity)
{
if (entity == null)
{
return (DimCountry, ModeAll, 0);
}
if (!string.IsNullOrWhiteSpace(entity.MatchDim))
{
return (
NormalizeDim(entity.MatchDim) ?? DimCountry,
NormalizeMode(entity.MatchMode) ?? ModeAll,
entity.PrefixLen);
}
var rule = Compile(entity, attachTag: false);
if (rule == null)
{
return (DimCountry, ModeAll, 0);
}
return (rule.MatchDim, rule.MatchMode, rule.PrefixLen);
}
private static GeoRule? Compile(
string? countryCode,
string? province,
string? city,
string? leftPostcode,
string? rightPostcode,
string? postcodeRange,
string? matchDim,
string? matchMode,
int prefixLenHint,
int ordinal,
object? tag)
{
var country = NormalizeCountry(countryCode);
if (string.IsNullOrEmpty(country))
{
return null;
}
var dim = NormalizeDim(matchDim);
if (string.IsNullOrEmpty(dim))
{
dim = InferDim(leftPostcode, rightPostcode, province, city, postcodeRange);
}
var encoded = TryParseEncodedRange(postcodeRange);
var zipLeft = NormalizeZip(leftPostcode);
var zipRight = NormalizeZip(rightPostcode);
var zipMode = NormalizeMode(matchMode) ?? encoded?.Mode;
var zipText = encoded?.Text ?? string.Empty;
var zipStart = encoded?.Start ?? string.Empty;
var zipEnd = encoded?.End ?? string.Empty;
var prefixLen = prefixLenHint > 0 ? prefixLenHint : encoded?.PrefixLen ?? 0;
if (zipMode is not (ModePrefix or ModeExact or ModeRange))
{
zipMode = encoded?.Mode;
}
if (zipMode == null)
{
if (zipLeft.Length > 0 && zipRight.Length == 0)
{
zipMode = ModePrefix;
zipText = zipLeft;
prefixLen = zipLeft.Length;
}
else if (zipLeft.Length > 0 && zipLeft == zipRight)
{
zipMode = ModeExact;
zipText = zipLeft;
prefixLen = zipLeft.Length;
}
else if (zipLeft.Length > 0 && zipRight.Length > 0)
{
zipMode = ModeRange;
zipStart = zipLeft;
zipEnd = zipRight;
prefixLen = Math.Min(zipLeft.Length, zipRight.Length);
}
}
else if (dim == DimZip)
{
if (zipMode == ModePrefix)
{
zipText = zipLeft.Length > 0 ? zipLeft : zipText;
prefixLen = prefixLen > 0 ? prefixLen : zipText.Length;
}
else if (zipMode == ModeExact)
{
zipText = zipLeft.Length > 0 ? zipLeft : zipText;
prefixLen = zipText.Length;
}
else if (zipMode == ModeRange)
{
zipStart = zipLeft.Length > 0 ? zipLeft : zipStart;
zipEnd = zipRight.Length > 0 ? zipRight : zipEnd;
prefixLen = Math.Min(
zipStart.Length == 0 ? prefixLen : zipStart.Length,
zipEnd.Length == 0 ? prefixLen : zipEnd.Length);
}
}
if (dim == DimZip)
{
if (zipMode == null)
{
return null;
}
return new GeoRule
{
Ordinal = ordinal,
Tag = tag,
CountryCode = country,
MatchDim = DimZip,
MatchMode = zipMode,
PrefixLen = prefixLen,
MatchTextStd = zipText,
ZipStartStd = zipStart,
ZipEndStd = zipEnd
};
}
var state = NormalizeRegion(province);
var cityStd = NormalizeRegion(city);
if (dim == DimCity)
{
if (string.IsNullOrEmpty(state) || string.IsNullOrEmpty(cityStd))
{
return null;
}
return new GeoRule
{
Ordinal = ordinal,
Tag = tag,
CountryCode = country,
MatchDim = DimCity,
MatchMode = ModeExact,
MatchTextStd = cityStd,
MatchStateStd = state
};
}
if (dim == DimState)
{
if (string.IsNullOrEmpty(state))
{
return null;
}
return new GeoRule
{
Ordinal = ordinal,
Tag = tag,
CountryCode = country,
MatchDim = DimState,
MatchMode = ModeExact,
MatchTextStd = state
};
}
return new GeoRule
{
Ordinal = ordinal,
Tag = tag,
CountryCode = country,
MatchDim = DimCountry,
MatchMode = ModeAll
};
}
private static void ApplyZipFields(KpiAreaInfo entity, string? requestedMode)
{
entity.Province = null;
entity.City = null;
var zipLeft = NormalizeZip(entity.LeftPostcode);
var zipRight = NormalizeZip(entity.RightPostcode);
if (zipLeft.Length == 0 && zipRight.Length > 0)
{
zipLeft = zipRight;
zipRight = string.Empty;
}
if (zipLeft.Length == 0)
{
throw new InvalidOperationException("邮编匹配必须填写起始邮编");
}
var mode = requestedMode;
if (mode is not (ModePrefix or ModeExact or ModeRange))
{
if (zipRight.Length == 0)
{
mode = ModePrefix;
}
else if (zipLeft == zipRight)
{
mode = ModeExact;
}
else
{
mode = ModeRange;
}
}
if (mode == ModePrefix)
{
entity.LeftPostcode = zipLeft;
entity.RightPostcode = null;
entity.PrefixLen = zipLeft.Length;
}
else if (mode == ModeExact)
{
entity.LeftPostcode = zipLeft;
entity.RightPostcode = zipLeft;
entity.PrefixLen = zipLeft.Length;
}
else
{
if (zipRight.Length == 0)
{
throw new InvalidOperationException("邮编区间必须填写截止邮编");
}
if (CompareNormalizedZip(zipLeft, zipRight) > 0)
{
throw new InvalidOperationException("起始邮编不能大于截止邮编");
}
entity.LeftPostcode = zipLeft;
entity.RightPostcode = zipRight;
entity.PrefixLen = Math.Min(zipLeft.Length, zipRight.Length);
}
entity.MatchDim = DimZip;
entity.MatchMode = mode;
}
private static string InferDim(
string? leftPostcode,
string? rightPostcode,
string? province,
string? city,
string? postcodeRange = null)
{
if (!string.IsNullOrWhiteSpace(postcodeRange)
|| !string.IsNullOrWhiteSpace(leftPostcode)
|| !string.IsNullOrWhiteSpace(rightPostcode))
{
return DimZip;
}
if (!string.IsNullOrWhiteSpace(city))
{
return DimCity;
}
if (!string.IsNullOrWhiteSpace(province))
{
return DimState;
}
return DimCountry;
}
private static string? NormalizeDim(string? value)
{
var raw = value?.Trim().ToLowerInvariant();
return raw switch
{
DimZip or "postcode" or "postal" or "邮编" => DimZip,
DimState or "province" or "州" or "州/省" or "省" => DimState,
DimCity or "城市" => DimCity,
DimCountry or "国家" or "仅国家" => DimCountry,
_ => null
};
}
private static string? NormalizeMode(string? value)
{
var raw = value?.Trim().ToLowerInvariant();
return raw switch
{
ModePrefix or "前缀" => ModePrefix,
ModeExact or "精确" or "exactmatch" => ModeExact,
ModeRange or "区间" => ModeRange,
ModeAll or "全部" => ModeAll,
_ => null
};
}
private static EncodedZip? TryParseEncodedRange(string? postcodeRange)
{
if (string.IsNullOrWhiteSpace(postcodeRange))
{
return null;
}
var raw = postcodeRange.Trim();
if (raw.StartsWith("prefix:", StringComparison.OrdinalIgnoreCase))
{
var text = NormalizeZip(raw["prefix:".Length..]);
if (text.Length == 0)
{
return null;
}
return new EncodedZip(ModePrefix, text, string.Empty, string.Empty, text.Length);
}
if (raw.StartsWith("exact:", StringComparison.OrdinalIgnoreCase))
{
var text = NormalizeZip(raw["exact:".Length..]);
if (text.Length == 0)
{
return null;
}
return new EncodedZip(ModeExact, text, string.Empty, string.Empty, text.Length);
}
var match = PostcodeRangePattern.Match(raw);
if (!match.Success)
{
return null;
}
var left = NormalizeZip(match.Groups[1].Value);
var right = NormalizeZip(match.Groups[2].Value);
if (left.Length == 0 && right.Length == 0)
{
return null;
}
if (left.Length == 0)
{
left = right;
}
if (right.Length == 0)
{
return new EncodedZip(ModePrefix, left, string.Empty, string.Empty, left.Length);
}
if (left == right)
{
return new EncodedZip(ModeExact, left, string.Empty, string.Empty, left.Length);
}
return new EncodedZip(ModeRange, string.Empty, left, right, Math.Min(left.Length, right.Length));
}
private static List CompileList(IEnumerable? sources, Func compile)
{
var list = new List();
if (sources == null)
{
return list;
}
var ordinal = 0;
foreach (var source in sources)
{
var rule = compile(source, ordinal++, true);
if (rule != null)
{
list.Add(rule);
}
}
return list;
}
private static int DimRank(GeoRule rule) =>
rule.MatchDim switch
{
DimZip => 1,
DimState => 2,
DimCity => 3,
DimCountry => 4,
_ => 9
};
private static int RangeSpecificity(GeoRule rule)
{
if (rule.MatchDim != DimZip)
{
return 0;
}
if (rule.MatchMode == ModeExact)
{
return rule.MatchTextStd.Length * 10;
}
if (rule.MatchMode == ModeRange)
{
return Math.Min(rule.ZipStartStd.Length, rule.ZipEndStd.Length);
}
return 0;
}
private static string? EmptyToNull(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private sealed record EncodedZip(string Mode, string Text, string Start, string End, int PrefixLen);
}