20261008-ide1.0发布 #1

Merged
zzh merged 42 commits from test into master 2026-10-08 09:18:00 +08:00
20 changed files with 954 additions and 197 deletions
Showing only changes of commit bb8c0a906c - Show all commits

View File

@ -0,0 +1,56 @@
-- 分区包发布表(价区 / 黑名单 / 偏远):卖价/规则发布钉包,计费按 version 加载
SET @db := DATABASE();
SET @tbl := (
SELECT COUNT(1)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_kpi_area_publish_record'
);
SET @sql := IF(
@tbl = 0,
'CREATE TABLE `t_kpi_area_publish_record` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
`AreaId` INT NOT NULL COMMENT ''所属分区表 t_kpi_area.Id'',
`AreaType` VARCHAR(32) NOT NULL COMMENT ''分区类型:price=价格分区,blacklist=黑名单,remote=偏远'',
`Version` VARCHAR(32) NOT NULL COMMENT ''包版本号(yyyyMMddHHmmssfff)'',
`Status` TINYINT NOT NULL DEFAULT 1 COMMENT ''状态:1=生效 Active,2=失效 Inactive'',
`ContentHash` VARCHAR(64) NOT NULL COMMENT ''规范化分区明细 Content 的 SHA256(hex),同 AreaId+Hash 去重复用'',
`Content` JSON NULL COMMENT ''分区明细快照 JSON(KpiAreaSnapshotRuleDto 数组)'',
`PublishUser` VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''发布人'',
`PublishTime` DATETIME NOT NULL COMMENT ''发布时间'',
`Description` VARCHAR(512) NULL COMMENT ''发布说明'',
`IsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''软删除:0=否,1=是'',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
`UpdateUserId` INT NULL COMMENT ''更新人'',
`UpdateTime` DATETIME NULL COMMENT ''更新时间'',
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_kpi_pack_area_hash` (`AreaId`, `ContentHash`),
KEY `idx_kpi_pack_area_ver` (`AreaId`, `Version`),
KEY `idx_kpi_pack_area_type_time` (`AreaId`, `AreaType`, `PublishTime`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''KPI分区发布包(价区/黑名单/偏远明细不可变快照)''',
'SELECT ''t_kpi_area_publish_record already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 表已存在时回填表/字段注释(可重复执行;不包进 PREPARE)
ALTER TABLE `t_kpi_area_publish_record`
COMMENT = 'KPI分区发布包(价区/黑名单/偏远明细不可变快照)',
MODIFY COLUMN `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
MODIFY COLUMN `AreaId` INT NOT NULL COMMENT '所属分区表 t_kpi_area.Id',
MODIFY COLUMN `AreaType` VARCHAR(32) NOT NULL COMMENT '分区类型:price=价格分区,blacklist=黑名单,remote=偏远',
MODIFY COLUMN `Version` VARCHAR(32) NOT NULL COMMENT '包版本号(yyyyMMddHHmmssfff)',
MODIFY COLUMN `Status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=生效 Active,2=失效 Inactive',
MODIFY COLUMN `ContentHash` VARCHAR(64) NOT NULL COMMENT '规范化分区明细 Content 的 SHA256(hex),同 AreaId+Hash 去重复用',
MODIFY COLUMN `Content` JSON NULL COMMENT '分区明细快照 JSON(KpiAreaSnapshotRuleDto 数组)',
MODIFY COLUMN `PublishUser` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '发布人',
MODIFY COLUMN `PublishTime` DATETIME NOT NULL COMMENT '发布时间',
MODIFY COLUMN `Description` VARCHAR(512) NULL COMMENT '发布说明',
MODIFY COLUMN `IsDeleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除:0=否,1=是',
MODIFY COLUMN `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
MODIFY COLUMN `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
MODIFY COLUMN `UpdateUserId` INT NULL COMMENT '更新人',
MODIFY COLUMN `UpdateTime` DATETIME NULL COMMENT '更新时间';

View File

@ -0,0 +1,28 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// KPI 分区包版本头/详情。
/// </summary>
public class KpiAreaPublishRecordOutput
{
public int Id { get; set; }
public int AreaId { get; set; }
public string AreaType { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string ContentHash { get; set; } = string.Empty;
public string PublishUser { get; set; } = string.Empty;
public DateTime PublishTime { get; set; }
public string? Description { get; set; }
/// <summary>仅 get 接口返回正文。</summary>
public object? Content { get; set; }
}

View File

@ -0,0 +1,22 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 销售产品发布同步态(轻量,不含快照正文)。
/// </summary>
public class SalesProductPublishSyncStatusOutput
{
/// <summary>相对最新生效版本是否有待发布变更。</summary>
public bool Pending { get; set; }
/// <summary>最新生效版本号;无则 null。</summary>
public string? OnlineVersion { get; set; }
/// <summary>最新生效发布时间。</summary>
public DateTime? OnlinePublishTime { get; set; }
/// <summary>待发布快照内容哈希(规范化后)。</summary>
public string? PendingContentHash { get; set; }
/// <summary>线上快照内容哈希。</summary>
public string? OnlineContentHash { get; set; }
}

View File

@ -0,0 +1,17 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 卖价/规则快照内对分区包的引用(明细在 t_kpi_area_publish_record)。
/// </summary>
public class KpiAreaPackRefDto
{
public int AreaId { get; set; }
public string? AreaName { get; set; }
/// <summary>分区包 Version(yyyyMMddHHmmss)。</summary>
public string PackVersion { get; set; } = string.Empty;
/// <summary>包 ContentHash,便于同步态对比。</summary>
public string? ContentHash { get; set; }
}

View File

@ -1,9 +1,10 @@
using System.Text.Json.Serialization;
using Oms.Application.Dtos.Channels;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 销售产品价格发布快照(与渠道发布 JSON 同构)。
/// 销售产品价格发布快照。
/// </summary>
public class SalesProductPricePublishSnapshotDto
{
@ -13,7 +14,7 @@ public class SalesProductPricePublishSnapshotDto
public int SalesProductId { get; set; }
/// <summary>
/// 价格分区 ID(发布时固化,查价/计费优先用快照内分区规则)。
/// 价格分区 ID(单表时冗余展示;以 PriceAreaRefs 为准)。
/// </summary>
public int? PriceAreaId { get; set; }
@ -23,8 +24,14 @@ public class SalesProductPricePublishSnapshotDto
public string? PriceAreaName { get; set; }
/// <summary>
/// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化;邮编为闭区间。
/// 价格分区包引用(发布时钉包;明细在 t_kpi_area_publish_record)。
/// </summary>
public List<KpiAreaPackRefDto> PriceAreaRefs { get; set; } = [];
/// <summary>
/// 运行时 hydrate / LIVE 用分区明细,不落发布 JSON。
/// </summary>
[JsonIgnore]
public List<KpiAreaSnapshotRuleDto> PriceAreaInfos { get; set; } = [];
/// <summary>
@ -34,7 +41,7 @@ public class SalesProductPricePublishSnapshotDto
}
/// <summary>
/// 销售产品规则发布快照(与渠道发布 JSON 同构)。
/// 销售产品规则发布快照。
/// </summary>
public class SalesProductRulesPublishSnapshotDto
{
@ -49,7 +56,7 @@ public class SalesProductRulesPublishSnapshotDto
public List<SalesProductAllocationProviderSnapshotDto> Providers { get; set; } = [];
/// <summary>
/// 黑名单分区 ID(发布时固化)。
/// 黑名单分区 ID。
/// </summary>
public int? BlacklistAreaId { get; set; }
@ -59,12 +66,18 @@ public class SalesProductRulesPublishSnapshotDto
public string? BlacklistAreaName { get; set; }
/// <summary>
/// 黑名单分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。
/// 黑名单分区包引用。
/// </summary>
public KpiAreaPackRefDto? BlacklistAreaRef { get; set; }
/// <summary>
/// 运行时 hydrate / LIVE 用,不落发布 JSON。
/// </summary>
[JsonIgnore]
public List<KpiAreaSnapshotRuleDto> BlacklistAreaInfos { get; set; } = [];
/// <summary>
/// 偏远分区 ID(发布时固化)。
/// 偏远分区 ID。
/// </summary>
public int? RemoteAreaId { get; set; }
@ -74,8 +87,14 @@ public class SalesProductRulesPublishSnapshotDto
public string? RemoteAreaName { get; set; }
/// <summary>
/// 偏远分区明细,发布时从 t_kpi_area_info 固化;邮编为闭区间。
/// 偏远分区包引用。
/// </summary>
public KpiAreaPackRefDto? RemoteAreaRef { get; set; }
/// <summary>
/// 运行时 hydrate / LIVE 用,不落发布 JSON。
/// </summary>
[JsonIgnore]
public List<KpiAreaSnapshotRuleDto> RemoteAreaInfos { get; set; } = [];
/// <summary>

View File

@ -7,6 +7,7 @@ using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.Shared;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.Channels;
@ -390,4 +391,63 @@ public class KpiAreaService : ApplicationService
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};
}
/// <summary>
/// 分区包版本列表(不含正文)。
/// </summary>
[HttpGet("/channel/kpi-area/publish/list")]
public async Task<List<KpiAreaPublishRecordOutput>> GetPublishListAsync([FromQuery] int areaId)
{
if (areaId <= 0)
{
throw new Fake.Application.BusinessException("分区表 Id 无效");
}
await GetAreaOrThrowAsync(areaId);
var headers = await KpiAreaRepository.GetPublishHeadersByAreaIdAsync(areaId);
return headers.Select(MapPublishHeader).ToList();
}
/// <summary>
/// 单条分区包(含正文)。
/// </summary>
[HttpGet("/channel/kpi-area/publish/get")]
public async Task<KpiAreaPublishRecordOutput> GetPublishRecordAsync([FromQuery] int id)
{
var entity = await KpiAreaRepository.GetPublishByIdAsync(id)
?? throw new Fake.Application.BusinessException("分区包不存在");
return MapPublishDetail(entity);
}
private async Task<KpiArea> GetAreaOrThrowAsync(int areaId)
{
var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId);
if (area == null)
{
throw new Fake.Application.BusinessException("分区表不存在");
}
return area;
}
private static KpiAreaPublishRecordOutput MapPublishHeader(KpiAreaPublishRecord entity) =>
new()
{
Id = entity.Id,
AreaId = entity.AreaId,
AreaType = entity.AreaType,
Version = entity.Version,
Status = EnumDisplayNames.Of(entity.Status),
ContentHash = entity.ContentHash,
PublishUser = entity.PublishUser,
PublishTime = entity.PublishTime,
Description = entity.Description
};
private static KpiAreaPublishRecordOutput MapPublishDetail(KpiAreaPublishRecord entity)
{
var output = MapPublishHeader(entity);
output.Content = OmsDocHelper.DeserializeJsonObject(entity.Content);
return output;
}
}

View File

@ -38,8 +38,28 @@ public partial class SalesProductService
}
var warnings = new List<string>();
var priceSnapshot = await BuildPricePublishSnapshotAsync(product.Id);
var rulesSnapshot = await BuildRulesPublishSnapshotAsync(product.Id);
// 模拟用当前草稿:LIVE Infos,不强制走已发布包
var priceSnapshot = new SalesProductPricePublishSnapshotDto
{
SalesProductId = product.Id,
Prices = (await GetPriceOutputsAsync(product.Id)).Select(PublishSnapshotSlimmer.SlimPrice).ToList()
};
await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync(
priceSnapshot,
product,
KpiAreaRepository,
KpiAreaPackService);
var rulesSnapshot = new SalesProductRulesPublishSnapshotDto
{
Product = MapProductToRulesSaveInput(product)
};
await RulesSnapshotHelper.ApplyLiveRulesToSnapshotAsync(
rulesSnapshot,
product,
KpiAreaRepository,
WeightRuleRepository,
WeightRoundingRepository,
KpiAreaPackService);
var weight = new OrderWeight
{
@ -261,7 +281,7 @@ public partial class SalesProductService
return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
}
private async Task<bool> IsSimulateRemoteHitAsync(
private Task<bool> IsSimulateRemoteHitAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto rulesSnapshot,
string countryCode,
@ -269,24 +289,17 @@ public partial class SalesProductService
string? city,
string? zipCode)
{
var areaId = product.RemoteAreaId is > 0 ? product.RemoteAreaId : rulesSnapshot.RemoteAreaId;
if (areaId is not > 0)
{
return false;
}
if (rulesSnapshot.RemoteAreaInfos is { Count: > 0 })
{
return GeoPartitionMatcher.IsHit(
return Task.FromResult(GeoPartitionMatcher.IsHit(
rulesSnapshot.RemoteAreaInfos,
countryCode,
province,
city,
zipCode);
zipCode));
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
return Task.FromResult(false);
}
private async Task<string?> ResolveSimulateWarehouseNameAsync(int? warehouseId)

View File

@ -290,13 +290,15 @@ public partial class SalesProductService
product,
KpiAreaRepository,
WeightRuleRepository,
WeightRoundingRepository);
WeightRoundingRepository,
KpiAreaPackService,
ResolvePublishUser());
await AllocationProviderSnapshotEnricher.EnrichAsync(rulesSnapshot.Providers);
return rulesSnapshot;
}
/// <summary>
/// 组装价格发布快照(不落库)。
/// 组装价格发布快照(不落库);价区钉分区包 Refs。
/// </summary>
private async Task<SalesProductPricePublishSnapshotDto> BuildPricePublishSnapshotAsync(int salesProductId)
{
@ -306,10 +308,20 @@ public partial class SalesProductService
SalesProductId = salesProductId,
Prices = (await GetPriceOutputsAsync(salesProductId)).Select(PublishSnapshotSlimmer.SlimPrice).ToList()
};
await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(priceSnapshot, product, KpiAreaRepository);
await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(
priceSnapshot,
product,
KpiAreaRepository,
KpiAreaPackService,
ResolvePublishUser());
return priceSnapshot;
}
private string ResolvePublishUser() =>
string.IsNullOrWhiteSpace(CurrentUser.UserName)
? CurrentUser.GetId<int>().ToString()
: CurrentUser.UserName!;
private static SalesProductSaveInput MapProductToRulesSaveInput(SalesProduct product) =>
new()
{

View File

@ -23,6 +23,7 @@ public partial class SalesProductService : ApplicationService
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService<KpiAreaPackService>();
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
@ -408,6 +409,67 @@ public partial class SalesProductService : ApplicationService
return OmsDocHelper.ToSnapshotApiContent(snapshot)!;
}
/// <summary>
/// 发布同步态(轻量):对比待发布与最新生效快照,不回传正文。
/// </summary>
[HttpGet("/channel/sales-product/publish/sync-status")]
public async Task<SalesProductPublishSyncStatusOutput> GetPublishSyncStatusAsync(
[FromQuery] int salesProductId,
[FromQuery] string operatorType)
{
var normalized = NormalizeOperatorType(operatorType);
await GetProductOrThrowAsync(salesProductId);
object pendingSnapshot = normalized == PriceOperatorType
? await BuildPricePublishSnapshotAsync(salesProductId)
: await BuildRulesPublishSnapshotAsync(salesProductId);
var pendingJson = OmsDocHelper.SerializeSnapshotJson(pendingSnapshot) ?? string.Empty;
var pendingHash = ComputeContentHash(pendingJson);
var headers = await SalesProductRepository.GetAllPublishRecordHeadersAsync(salesProductId, normalized);
var onlineHeader = headers
.Where(x => x.Status == Domain.Shared.Enums.EsSnapshotStatus.Active)
.OrderByDescending(x => x.PublishTime)
.FirstOrDefault();
if (onlineHeader == null)
{
return new SalesProductPublishSyncStatusOutput
{
Pending = true,
OnlineVersion = null,
OnlinePublishTime = null,
PendingContentHash = pendingHash,
OnlineContentHash = null
};
}
var onlineRecord = await SalesProductRepository.GetPublishRecordByIdAsync(onlineHeader.Id);
var onlineJson = onlineRecord?.Content ?? string.Empty;
// 线上 Content 与待发布统一经反序列化再序列化,避免字段顺序噪声
object? onlineNormalized = normalized == PriceOperatorType
? OmsDocHelper.DeserializePriceSnapshot(onlineJson)
: OmsDocHelper.DeserializeRulesSnapshot(onlineJson);
var onlineCanon = OmsDocHelper.SerializeSnapshotJson(onlineNormalized) ?? string.Empty;
var onlineHash = ComputeContentHash(onlineCanon);
return new SalesProductPublishSyncStatusOutput
{
Pending = !string.Equals(pendingHash, onlineHash, StringComparison.Ordinal),
OnlineVersion = onlineHeader.Version,
OnlinePublishTime = onlineHeader.PublishTime,
PendingContentHash = pendingHash,
OnlineContentHash = onlineHash
};
}
private static string ComputeContentHash(string content)
{
var hash = global::System.Security.Cryptography.SHA256.HashData(
global::System.Text.Encoding.UTF8.GetBytes(content));
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// 执行销售产品发布。
/// </summary>

View File

@ -51,6 +51,7 @@ public partial class OrderService
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService<KpiAreaPackService>();
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>();
@ -552,7 +553,7 @@ public partial class OrderService
throw new BusinessException("未命中规则快照版本(内存/Redis/DB)");
}
return new ProductSnapshotBinding
var binding = new ProductSnapshotBinding
{
RulesVersion = rulesRecord?.Version ?? $"LIVE-{product.Id}",
PriceVersion = priceRecord?.Version ?? $"LIVE-{product.Id}",
@ -561,6 +562,8 @@ public partial class OrderService
Rules = rulesSnapshot,
Price = priceSnapshot
};
await KpiAreaPackService.HydrateBindingAsync(binding);
return binding;
}
private async Task<ProductSnapshotBinding> ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order)
@ -632,7 +635,7 @@ public partial class OrderService
priceEffectiveTime = priceRecord.PublishTime;
}
return new ProductSnapshotBinding
var binding = new ProductSnapshotBinding
{
RulesVersion = rulesVersion,
PriceVersion = priceVersion,
@ -641,6 +644,8 @@ public partial class OrderService
Rules = rulesSnapshot,
Price = priceSnapshot
};
await KpiAreaPackService.HydrateBindingAsync(binding);
return binding;
}
private async Task<string?> ResolveWarehouseNameAsync(int? warehouseId)
@ -1412,12 +1417,13 @@ public partial class OrderService
Scheme = product.Scheme
}
};
await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
await RulesSnapshotHelper.ApplyLiveRulesToSnapshotAsync(
snapshot,
product,
KpiAreaRepository,
WeightRuleRepository,
WeightRoundingRepository);
WeightRoundingRepository,
KpiAreaPackService);
snapshot.Providers = await ResolveAllocationProvidersAsync(product, snapshot);
return snapshot;
}
@ -1455,7 +1461,11 @@ public partial class OrderService
: []
})).ToList()
};
await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, KpiAreaRepository);
await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync(
snapshot,
product,
KpiAreaRepository,
KpiAreaPackService);
return snapshot;
}
@ -1484,7 +1494,7 @@ public partial class OrderService
};
}
private async Task<bool> IsBlacklistHitAsync(
private Task<bool> IsBlacklistHitAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
string countryCode,
@ -1492,27 +1502,26 @@ public partial class OrderService
string? city,
string? zipCode)
{
var areaId = ResolveEffectiveBlacklistAreaId(product, rulesSnapshot);
if (!areaId.HasValue)
if (rulesSnapshot?.BlacklistAreaRef != null && rulesSnapshot.BlacklistAreaInfos is not { Count: > 0 })
{
return false;
throw new BusinessException(
$"黑名单分区包未加载: areaId={rulesSnapshot.BlacklistAreaRef.AreaId} version={rulesSnapshot.BlacklistAreaRef.PackVersion}");
}
if (rulesSnapshot?.BlacklistAreaInfos is { Count: > 0 })
{
return GeoPartitionMatcher.IsHit(
return Task.FromResult(GeoPartitionMatcher.IsHit(
rulesSnapshot.BlacklistAreaInfos,
countryCode,
province,
city,
zipCode);
zipCode));
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
return Task.FromResult(false);
}
private async Task<bool> IsRemoteHitAsync(
private Task<bool> IsRemoteHitAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
string countryCode,
@ -1520,24 +1529,23 @@ public partial class OrderService
string? city,
string? zipCode)
{
var areaId = ResolveEffectiveRemoteAreaId(product, rulesSnapshot);
if (!areaId.HasValue)
if (rulesSnapshot?.RemoteAreaRef != null && rulesSnapshot.RemoteAreaInfos is not { Count: > 0 })
{
return false;
throw new BusinessException(
$"偏远分区包未加载: areaId={rulesSnapshot.RemoteAreaRef.AreaId} version={rulesSnapshot.RemoteAreaRef.PackVersion}");
}
if (rulesSnapshot?.RemoteAreaInfos is { Count: > 0 })
{
return GeoPartitionMatcher.IsHit(
return Task.FromResult(GeoPartitionMatcher.IsHit(
rulesSnapshot.RemoteAreaInfos,
countryCode,
province,
city,
zipCode);
zipCode));
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value);
return GeoPartitionMatcher.IsHit(rules, countryCode, province, city, zipCode);
return Task.FromResult(false);
}
private static int? ResolveEffectiveBlacklistAreaId(
@ -1607,7 +1615,7 @@ public partial class OrderService
return null;
}
private async Task<IReadOnlyList<string>?> ResolvePriceZoneKeysAsync(
private Task<IReadOnlyList<string>?> ResolvePriceZoneKeysAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
SalesProductPricePublishSnapshotDto? priceSnapshot,
@ -1616,42 +1624,24 @@ public partial class OrderService
string? city,
string? zipCode)
{
if (priceSnapshot?.PriceAreaRefs is { Count: > 0 }
&& priceSnapshot.PriceAreaInfos is not { Count: > 0 })
{
throw new BusinessException("价格分区包未加载,无法匹配分区");
}
if (priceSnapshot?.PriceAreaInfos is { Count: > 0 })
{
return ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(
return Task.FromResult(ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(
priceSnapshot,
countryCode,
province,
city,
zipCode);
zipCode));
}
var priceAreaId = ResolveEffectivePriceAreaId(product, rulesSnapshot, priceSnapshot);
if (!priceAreaId.HasValue)
{
return null;
}
var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == priceAreaId.Value);
if (area == null)
{
return null;
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value);
var matched = GeoPartitionMatcher.MatchEntity(
rules,
countryCode,
province,
city,
zipCode);
if (matched == null)
{
return null;
}
var keys = ParcelBillingCalculator.BuildPriceZoneKeys(matched, area);
return keys.Count > 0 ? keys : null;
// 无分区配置:返回 null;禁止回读线上 KPI(正式路径须走包)
return Task.FromResult<IReadOnlyList<string>?>(null);
}
private async Task ApplyChargeableWeightAsync(

View File

@ -737,7 +737,13 @@ public partial class OrderService
}
// 仅国家+重量:分区规则带邮编时无法精确命中,按「同国家」分区给出估算价;
// 快照中完全没有该国家时不跨国家兜底。
// 须已 hydrate;禁止回读线上 KPI。
if (snapshotBinding.Price?.PriceAreaRefs is { Count: > 0 }
&& snapshotBinding.Price.PriceAreaInfos is not { Count: > 0 })
{
throw new BusinessException("价格分区包未加载,无法估算分区");
}
if (snapshotBinding.Price?.PriceAreaInfos is { Count: > 0 })
{
foreach (var zoneKey in ParcelBillingCalculator.CollectCountryEstimateZoneKeys(
@ -746,42 +752,6 @@ public partial class OrderService
{
attempts.Add(new QuoteZoneAttempt([zoneKey], true, zoneKey));
}
return attempts;
}
// 无快照分区明细时,退回库表:同国家且不要求邮编的规则
var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price);
if (priceAreaId is > 0)
{
var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == priceAreaId.Value);
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value);
foreach (var rule in rules)
{
if (!string.IsNullOrWhiteSpace(rule.CountryCode)
&& !string.Equals(rule.CountryCode, context.CountryCode, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!string.IsNullOrWhiteSpace(rule.Province) || !string.IsNullOrWhiteSpace(rule.City))
{
continue;
}
var keys = ParcelBillingCalculator.BuildPriceZoneKeys(rule, area);
if (keys.Count == 0)
{
continue;
}
if (attempts.Any(x => x.ZoneKeys.SequenceEqual(keys, StringComparer.OrdinalIgnoreCase)))
{
continue;
}
attempts.Add(new QuoteZoneAttempt(keys.ToList(), true, keys[0]));
}
}
return attempts;

View File

@ -25,6 +25,7 @@ public class PickupOrderFeePushService : IScopedDependency
private readonly IWeightRoundingRepository _weightRoundingRepository;
private readonly IKpiAreaRepository _kpiAreaRepository;
private readonly IWarehouseAddressRepository _warehouseAddressRepository;
private readonly KpiAreaPackService _kpiAreaPackService;
public PickupOrderFeePushService(
IPickupOrderRepository pickupOrderRepository,
@ -33,7 +34,8 @@ public class PickupOrderFeePushService : IScopedDependency
IWeightRuleRepository weightRuleRepository,
IWeightRoundingRepository weightRoundingRepository,
IKpiAreaRepository kpiAreaRepository,
IWarehouseAddressRepository warehouseAddressRepository)
IWarehouseAddressRepository warehouseAddressRepository,
KpiAreaPackService kpiAreaPackService)
{
_pickupOrderRepository = pickupOrderRepository;
_salesProductRepository = salesProductRepository;
@ -42,6 +44,7 @@ public class PickupOrderFeePushService : IScopedDependency
_weightRoundingRepository = weightRoundingRepository;
_kpiAreaRepository = kpiAreaRepository;
_warehouseAddressRepository = warehouseAddressRepository;
_kpiAreaPackService = kpiAreaPackService;
}
public async Task<PickupOpenApiPushFeeResult> PushFeeAsync(PickupOpenApiPushFeeInput? input)
@ -245,7 +248,11 @@ public class PickupOrderFeePushService : IScopedDependency
SalesProductId = product.Id,
Prices = (await GetSellPriceOutputsAsync(product.Id)).Select(PublishSnapshotSlimmer.SlimPrice).ToList()
};
await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, _kpiAreaRepository);
await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync(
snapshot,
product,
_kpiAreaRepository,
_kpiAreaPackService);
return snapshot;
}

View File

@ -0,0 +1,317 @@
using System.Security.Cryptography;
using System.Text;
using Fake.Application;
using Fake.DependencyInjection;
using Microsoft.Extensions.Caching.Memory;
using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.Shared.Enums;
namespace Oms.Application.Shared;
/// <summary>
/// KPI 分区包:发布时 Ensure(ContentHash 去重),计费按 version 加载(缓存),缺失严格失败。
/// </summary>
public class KpiAreaPackService : IScopedDependency
{
public const string AreaTypePrice = "price";
public const string AreaTypeBlacklist = "blacklist";
public const string AreaTypeRemote = "remote";
private static readonly MemoryCache PackCache = new(new MemoryCacheOptions
{
SizeLimit = 256
});
private readonly IKpiAreaRepository _kpiAreaRepository;
public KpiAreaPackService(IKpiAreaRepository kpiAreaRepository)
{
_kpiAreaRepository = kpiAreaRepository;
}
/// <summary>
/// 按当前库明细组包;同 AreaId+ContentHash 复用已有 Version。
/// </summary>
public async Task<KpiAreaPackRefDto> EnsurePackAsync(
int areaId,
string areaType,
string? publishUser = null,
string? description = null,
CancellationToken cancellationToken = default)
{
if (areaId <= 0)
{
throw new BusinessException("分区表 Id 无效");
}
var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId, cancellationToken)
?? throw new BusinessException($"分区表不存在: {areaId}");
var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId, cancellationToken);
var rules = infos.Select(info => KpiAreaSnapshotMapper.MapToSnapshotRule(info, area.Name)).ToList();
PublishSnapshotNormalizer.NormalizeKpiAreas(rules);
rules = rules.OrderBy(x => x.Id).ToList();
var contentJson = OmsDocHelper.SerializeSnapshotJson(rules) ?? "[]";
var contentHash = ComputeSha256Hex(contentJson);
var existing = await _kpiAreaRepository.FindPublishByAreaIdAndContentHashAsync(
areaId,
contentHash,
cancellationToken);
if (existing != null)
{
return ToRef(existing, area.Name);
}
var version = DateTime.Now.ToString("yyyyMMddHHmmssfff");
var record = new KpiAreaPublishRecord
{
AreaId = areaId,
AreaType = string.IsNullOrWhiteSpace(areaType) ? area.AreaType : areaType.Trim(),
Version = version,
Status = EsSnapshotStatus.Active,
ContentHash = contentHash,
Content = contentJson,
PublishUser = string.IsNullOrWhiteSpace(publishUser) ? "system" : publishUser.Trim(),
PublishTime = DateTime.Now,
Description = description
};
record = await _kpiAreaRepository.InsertPublishRecordAsync(record, cancellationToken);
PackCache.Set(CacheKey(areaId, record.Version), rules, new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = TimeSpan.FromHours(6)
});
return ToRef(record, area.Name);
}
/// <summary>
/// 按引用加载分区规则;包不存在则严格失败。
/// </summary>
public async Task<List<KpiAreaSnapshotRuleDto>> GetRulesOrThrowAsync(
KpiAreaPackRefDto? packRef,
CancellationToken cancellationToken = default)
{
if (packRef == null || packRef.AreaId <= 0 || string.IsNullOrWhiteSpace(packRef.PackVersion))
{
throw new BusinessException("分区包引用无效");
}
return await GetRulesOrThrowAsync(packRef.AreaId, packRef.PackVersion, cancellationToken);
}
public async Task<List<KpiAreaSnapshotRuleDto>> GetRulesOrThrowAsync(
int areaId,
string packVersion,
CancellationToken cancellationToken = default)
{
var key = CacheKey(areaId, packVersion);
if (PackCache.TryGetValue(key, out List<KpiAreaSnapshotRuleDto>? cached) && cached != null)
{
return cached;
}
var record = await _kpiAreaRepository.GetPublishByAreaIdAndVersionAsync(
areaId,
packVersion,
cancellationToken);
if (record == null || string.IsNullOrWhiteSpace(record.Content))
{
throw new BusinessException($"分区包不存在: areaId={areaId} version={packVersion}");
}
var rules = OmsDocHelper.DeserializeJson<List<KpiAreaSnapshotRuleDto>>(record.Content) ?? [];
PublishSnapshotNormalizer.NormalizeKpiAreas(rules);
PackCache.Set(key, rules, new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = TimeSpan.FromHours(6)
});
return rules;
}
/// <summary>
/// 将卖价快照中的 PriceAreaRefs hydrate 到 PriceAreaInfos(运行时,不回写库)。
/// </summary>
public async Task HydratePriceSnapshotAsync(
SalesProductPricePublishSnapshotDto snapshot,
CancellationToken cancellationToken = default)
{
// LIVE/模拟:无 Refs,Infos 已由 LoadLive 填充,勿清空
if (snapshot.PriceAreaRefs is not { Count: > 0 })
{
return;
}
var merged = new List<KpiAreaSnapshotRuleDto>();
var seen = new HashSet<int>();
foreach (var packRef in snapshot.PriceAreaRefs)
{
var rules = await GetRulesOrThrowAsync(packRef, cancellationToken);
foreach (var rule in rules)
{
if (seen.Add(rule.Id))
{
merged.Add(rule);
}
}
}
snapshot.PriceAreaInfos = merged;
if (snapshot.PriceAreaRefs.Count == 1)
{
snapshot.PriceAreaId = snapshot.PriceAreaRefs[0].AreaId;
snapshot.PriceAreaName = snapshot.PriceAreaRefs[0].AreaName;
}
else
{
snapshot.PriceAreaId = null;
snapshot.PriceAreaName = string.Join(
"、",
snapshot.PriceAreaRefs
.Select(x => x.AreaName)
.Where(x => !string.IsNullOrWhiteSpace(x)));
}
}
/// <summary>
/// 将规则快照中的黑名单/偏远 Ref hydrate 到 Infos。
/// </summary>
public async Task HydrateRulesSnapshotAsync(
SalesProductRulesPublishSnapshotDto snapshot,
CancellationToken cancellationToken = default)
{
if (snapshot.BlacklistAreaRef != null)
{
snapshot.BlacklistAreaInfos = await GetRulesOrThrowAsync(snapshot.BlacklistAreaRef, cancellationToken);
snapshot.BlacklistAreaId = snapshot.BlacklistAreaRef.AreaId;
snapshot.BlacklistAreaName = snapshot.BlacklistAreaRef.AreaName;
}
// else:LIVE 保留已有 Infos
if (snapshot.RemoteAreaRef != null)
{
snapshot.RemoteAreaInfos = await GetRulesOrThrowAsync(snapshot.RemoteAreaRef, cancellationToken);
snapshot.RemoteAreaId = snapshot.RemoteAreaRef.AreaId;
snapshot.RemoteAreaName = snapshot.RemoteAreaRef.AreaName;
}
}
public async Task HydrateBindingAsync(
ProductSnapshotBinding binding,
CancellationToken cancellationToken = default)
{
await HydratePriceSnapshotAsync(binding.Price, cancellationToken);
await HydrateRulesSnapshotAsync(binding.Rules, cancellationToken);
}
/// <summary>
/// 从当前库装载分区规则到 Infos(仅 LIVE/模拟;不写包、不写 Ref)。
/// </summary>
public async Task LoadLiveInfosIntoPriceSnapshotAsync(
SalesProductPricePublishSnapshotDto snapshot,
IReadOnlyList<int> areaIds,
CancellationToken cancellationToken = default)
{
var merged = new List<KpiAreaSnapshotRuleDto>();
var seen = new HashSet<int>();
var names = new List<string>();
foreach (var areaId in areaIds.Where(id => id > 0).Distinct())
{
var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId, cancellationToken);
if (!string.IsNullOrWhiteSpace(area?.Name))
{
names.Add(area.Name.Trim());
}
var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId, cancellationToken);
foreach (var info in infos)
{
if (!seen.Add(info.Id))
{
continue;
}
merged.Add(KpiAreaSnapshotMapper.MapToSnapshotRule(info, area?.Name));
}
}
PublishSnapshotNormalizer.NormalizeKpiAreas(merged);
snapshot.PriceAreaInfos = merged;
snapshot.PriceAreaRefs = [];
if (areaIds.Count == 1)
{
snapshot.PriceAreaId = areaIds[0];
snapshot.PriceAreaName = names.Count > 0 ? names[0] : null;
}
else
{
snapshot.PriceAreaId = null;
snapshot.PriceAreaName = names.Count > 0 ? string.Join("、", names) : null;
}
}
public async Task LoadLiveInfosIntoRulesSnapshotAsync(
SalesProductRulesPublishSnapshotDto snapshot,
int? blacklistAreaId,
int? remoteAreaId,
CancellationToken cancellationToken = default)
{
snapshot.BlacklistAreaRef = null;
snapshot.RemoteAreaRef = null;
if (blacklistAreaId is > 0)
{
var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == blacklistAreaId.Value, cancellationToken);
var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(blacklistAreaId.Value, cancellationToken);
var rules = infos.Select(i => KpiAreaSnapshotMapper.MapToSnapshotRule(i, area?.Name)).ToList();
PublishSnapshotNormalizer.NormalizeKpiAreas(rules);
snapshot.BlacklistAreaId = blacklistAreaId;
snapshot.BlacklistAreaName = area?.Name;
snapshot.BlacklistAreaInfos = rules;
}
else
{
snapshot.BlacklistAreaId = null;
snapshot.BlacklistAreaName = null;
snapshot.BlacklistAreaInfos = [];
}
if (remoteAreaId is > 0)
{
var area = await _kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == remoteAreaId.Value, cancellationToken);
var infos = await _kpiAreaRepository.GetAllInfosByAreaIdAsync(remoteAreaId.Value, cancellationToken);
var rules = infos.Select(i => KpiAreaSnapshotMapper.MapToSnapshotRule(i, area?.Name)).ToList();
PublishSnapshotNormalizer.NormalizeKpiAreas(rules);
snapshot.RemoteAreaId = remoteAreaId;
snapshot.RemoteAreaName = area?.Name;
snapshot.RemoteAreaInfos = rules;
}
else
{
snapshot.RemoteAreaId = null;
snapshot.RemoteAreaName = null;
snapshot.RemoteAreaInfos = [];
}
}
private static KpiAreaPackRefDto ToRef(KpiAreaPublishRecord record, string? areaName) =>
new()
{
AreaId = record.AreaId,
AreaName = areaName,
PackVersion = record.Version,
ContentHash = record.ContentHash
};
private static string CacheKey(int areaId, string version) =>
$"kpi-area-pack:{areaId}:{version}";
private static string ComputeSha256Hex(string content)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@ -6,18 +6,19 @@ using Oms.Domain.ChannelsAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 将价格分区(t_kpi_area + t_kpi_area_info)固化进卖价发布快照。
/// 支持卖价明细引用多张价格分区表:合并各表明细规则写入快照。
/// 卖价发布:解析价区表 Id 并钉分区包引用(不内嵌 Infos)。
/// </summary>
public static class PriceAreaSnapshotHelper
{
/// <summary>
/// 按产品 PriceAreaId(或从卖价明细分区推断的全部 AreaId)加载分区头与明细写入快照。
/// 写入 PriceAreaRefs(Ensure 分区包);Infos 仅 LIVE 路径由 PackService.LoadLive 填充。
/// </summary>
public static async Task ApplyPriceAreaToSnapshotAsync(
SalesProductPricePublishSnapshotDto snapshot,
SalesProduct product,
IKpiAreaRepository kpiAreaRepository,
KpiAreaPackService packService,
string? publishUser = null,
CancellationToken cancellationToken = default)
{
var areaIds = await CollectResolvedPriceAreaIdsAsync(
@ -25,53 +26,59 @@ public static class PriceAreaSnapshotHelper
snapshot,
kpiAreaRepository,
cancellationToken);
snapshot.PriceAreaInfos = [];
if (areaIds.Count == 0)
{
snapshot.PriceAreaId = null;
snapshot.PriceAreaName = null;
snapshot.PriceAreaInfos = [];
snapshot.PriceAreaRefs = [];
return;
}
var mergedInfos = new List<KpiAreaSnapshotRuleDto>();
var seenInfoIds = new HashSet<int>();
var areaNames = new List<string>();
var refs = new List<KpiAreaPackRefDto>();
foreach (var areaId in areaIds)
{
var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId, cancellationToken);
var areaName = area?.Name;
if (!string.IsNullOrWhiteSpace(areaName))
{
areaNames.Add(areaName.Trim());
}
var infos = await kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId, cancellationToken);
foreach (var info in infos)
{
if (!seenInfoIds.Add(info.Id))
{
continue;
}
mergedInfos.Add(KpiAreaSnapshotMapper.MapToSnapshotRule(info, areaName));
}
refs.Add(await packService.EnsurePackAsync(
areaId,
KpiAreaPackService.AreaTypePrice,
publishUser,
description: "sales-product-price-publish",
cancellationToken));
}
snapshot.PriceAreaInfos = mergedInfos;
if (areaIds.Count == 1)
snapshot.PriceAreaRefs = refs;
if (refs.Count == 1)
{
snapshot.PriceAreaId = areaIds[0];
snapshot.PriceAreaName = areaNames.Count > 0 ? areaNames[0] : null;
snapshot.PriceAreaId = refs[0].AreaId;
snapshot.PriceAreaName = refs[0].AreaName;
}
else
{
// 多表:不再落单一 PriceAreaId,名称拼接便于人工查看
snapshot.PriceAreaId = null;
snapshot.PriceAreaName = areaNames.Count > 0 ? string.Join("、", areaNames) : null;
snapshot.PriceAreaName = string.Join(
"、",
refs.Select(x => x.AreaName).Where(x => !string.IsNullOrWhiteSpace(x)));
}
}
/// <summary>
/// LIVE/模拟:不钉包,直接从当前库装载 Infos。
/// </summary>
public static async Task ApplyLivePriceAreaInfosAsync(
SalesProductPricePublishSnapshotDto snapshot,
SalesProduct product,
IKpiAreaRepository kpiAreaRepository,
KpiAreaPackService packService,
CancellationToken cancellationToken = default)
{
var areaIds = await CollectResolvedPriceAreaIdsAsync(
product,
snapshot,
kpiAreaRepository,
cancellationToken);
await packService.LoadLiveInfosIntoPriceSnapshotAsync(snapshot, areaIds, cancellationToken);
}
/// <summary>
/// 收集卖价明细(及产品 PriceAreaId)可解析到的全部价格分区表 Id,去重保序。
/// </summary>
@ -106,9 +113,6 @@ public static class PriceAreaSnapshotHelper
return result;
}
/// <summary>
/// 兼容旧调用:仅返回第一个可解析 AreaId(多表场景请用 CollectResolvedPriceAreaIdsAsync)。
/// </summary>
internal static async Task<int?> ResolvePriceAreaIdAsync(
SalesProduct product,
SalesProductPricePublishSnapshotDto snapshot,

View File

@ -1,17 +1,16 @@
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 将黑名单/偏远/计重/进位规则固化进销售产品规则发布快照。
/// 规则发布:黑名单/偏远钉分区包;计重/进位仍内嵌明细。
/// </summary>
public static class RulesSnapshotHelper
{
/// <summary>
/// 按产品配置加载 KPI 分区、计重规则、进位区间写入规则快照。
/// 发布路径:黑名单/偏远 Ensure 包写 Ref;计重/进位写 Infos。
/// </summary>
public static async Task ApplyRulesToSnapshotAsync(
SalesProductRulesPublishSnapshotDto snapshot,
@ -19,29 +18,37 @@ public static class RulesSnapshotHelper
IKpiAreaRepository kpiAreaRepository,
IWeightRuleRepository weightRuleRepository,
IWeightRoundingRepository weightRoundingRepository,
KpiAreaPackService packService,
string? publishUser = null,
CancellationToken cancellationToken = default)
{
await ApplyKpiAreaToSnapshotAsync(
await ApplyKpiAreaPackRefAsync(
snapshot,
product.BlacklistAreaId,
kpiAreaRepository,
(s, areaId, areaName, infos) =>
KpiAreaPackService.AreaTypeBlacklist,
packService,
publishUser,
(s, areaId, areaName, packRef) =>
{
s.BlacklistAreaId = areaId;
s.BlacklistAreaName = areaName;
s.BlacklistAreaInfos = infos;
s.BlacklistAreaRef = packRef;
s.BlacklistAreaInfos = [];
},
cancellationToken);
await ApplyKpiAreaToSnapshotAsync(
await ApplyKpiAreaPackRefAsync(
snapshot,
product.RemoteAreaId,
kpiAreaRepository,
(s, areaId, areaName, infos) =>
KpiAreaPackService.AreaTypeRemote,
packService,
publishUser,
(s, areaId, areaName, packRef) =>
{
s.RemoteAreaId = areaId;
s.RemoteAreaName = areaName;
s.RemoteAreaInfos = infos;
s.RemoteAreaRef = packRef;
s.RemoteAreaInfos = [];
},
cancellationToken);
@ -49,6 +56,27 @@ public static class RulesSnapshotHelper
await ApplyWeightRoundingToSnapshotAsync(snapshot, product, weightRoundingRepository, cancellationToken);
}
/// <summary>
/// LIVE/模拟:黑名单/偏远从当前库装 Infos,不写 Ref。
/// </summary>
public static async Task ApplyLiveRulesToSnapshotAsync(
SalesProductRulesPublishSnapshotDto snapshot,
SalesProduct product,
IKpiAreaRepository kpiAreaRepository,
IWeightRuleRepository weightRuleRepository,
IWeightRoundingRepository weightRoundingRepository,
KpiAreaPackService packService,
CancellationToken cancellationToken = default)
{
await packService.LoadLiveInfosIntoRulesSnapshotAsync(
snapshot,
product.BlacklistAreaId,
product.RemoteAreaId,
cancellationToken);
await ApplyWeightRuleToSnapshotAsync(snapshot, product, weightRuleRepository, cancellationToken);
await ApplyWeightRoundingToSnapshotAsync(snapshot, product, weightRoundingRepository, cancellationToken);
}
/// <summary>
/// 从快照内嵌黑名单/偏远明细匹配地址(不查库)。
/// </summary>
@ -67,38 +95,34 @@ public static class RulesSnapshotHelper
return GeoPartitionMatcher.IsHit(infos, countryCode, province, city, zipCode);
}
/// <summary>
/// 将快照内嵌计重明细转为实体供计算器使用。
/// </summary>
public static List<WeightRuleInfo> ToWeightRuleEntities(IReadOnlyList<WeightRuleSnapshotRuleDto> infos) =>
infos.Select(PublishSnapshotSlimmer.ToWeightRuleEntity).ToList();
/// <summary>
/// 将快照内嵌进位区间转为实体供计算器使用。
/// </summary>
public static List<WeightRoundingInfo> ToWeightRoundingEntities(IReadOnlyList<WeightRoundingSnapshotRangeDto> infos) =>
infos.Select(PublishSnapshotSlimmer.ToWeightRoundingEntity).ToList();
private static async Task ApplyKpiAreaToSnapshotAsync(
private static async Task ApplyKpiAreaPackRefAsync(
SalesProductRulesPublishSnapshotDto snapshot,
int? areaId,
IKpiAreaRepository kpiAreaRepository,
Action<SalesProductRulesPublishSnapshotDto, int?, string?, List<KpiAreaSnapshotRuleDto>> assign,
string areaType,
KpiAreaPackService packService,
string? publishUser,
Action<SalesProductRulesPublishSnapshotDto, int?, string?, KpiAreaPackRefDto?> assign,
CancellationToken cancellationToken)
{
if (!areaId.HasValue || areaId.Value <= 0)
{
assign(snapshot, null, null, []);
assign(snapshot, null, null, null);
return;
}
var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == areaId.Value, cancellationToken);
var infos = await kpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value, cancellationToken);
assign(
snapshot,
areaId,
area?.Name,
infos.Select(info => KpiAreaSnapshotMapper.MapToSnapshotRule(info)).ToList());
var packRef = await packService.EnsurePackAsync(
areaId.Value,
areaType,
publishUser,
description: "sales-product-rules-publish",
cancellationToken);
assign(snapshot, areaId, packRef.AreaName, packRef);
}
private static async Task ApplyWeightRuleToSnapshotAsync(

View File

@ -0,0 +1,33 @@
using Oms.Domain.Shared;
using Oms.Domain.Shared.Enums;
using SqlSugar;
namespace Oms.Domain.ChannelsAggregate.Entities;
/// <summary>
/// KPI 分区发布包(价区/黑名单/偏远明细不可变快照)。
/// </summary>
[SugarTable("t_kpi_area_publish_record")]
public class KpiAreaPublishRecord : OmsAuditedEntity
{
public int AreaId { get; set; }
/// <summary>price / blacklist / remote</summary>
public string AreaType { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public EsSnapshotStatus Status { get; set; } = EsSnapshotStatus.Active;
public string ContentHash { get; set; } = string.Empty;
[SugarColumn(ColumnDataType = "json", IsNullable = true)]
public string? Content { get; set; }
public string PublishUser { get; set; } = string.Empty;
public DateTime PublishTime { get; set; }
[SugarColumn(IsNullable = true, Length = 512)]
public string? Description { get; set; }
}

View File

@ -0,0 +1,31 @@
using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Domain.ChannelsAggregate.Repositories;
/// <summary>
/// KPI 分区发布包读写(可由 <see cref="IKpiAreaRepository"/> 一并实现)。
/// </summary>
public interface IKpiAreaPublishRepository
{
Task<KpiAreaPublishRecord?> FindPublishByAreaIdAndContentHashAsync(
int areaId,
string contentHash,
CancellationToken cancellationToken = default);
Task<KpiAreaPublishRecord?> GetPublishByAreaIdAndVersionAsync(
int areaId,
string version,
CancellationToken cancellationToken = default);
Task<List<KpiAreaPublishRecord>> GetPublishHeadersByAreaIdAsync(
int areaId,
CancellationToken cancellationToken = default);
Task<KpiAreaPublishRecord?> GetPublishByIdAsync(
int id,
CancellationToken cancellationToken = default);
Task<KpiAreaPublishRecord> InsertPublishRecordAsync(
KpiAreaPublishRecord record,
CancellationToken cancellationToken = default);
}

View File

@ -3,7 +3,7 @@ using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Domain.ChannelsAggregate.Repositories;
public interface IKpiAreaRepository : IRepository<KpiArea>
public interface IKpiAreaRepository : IRepository<KpiArea>, IKpiAreaPublishRepository
{
Task<KpiArea?> GetByAppKeyTypeAndNameAsync(string appKey, string areaType, string name, CancellationToken cancellationToken = default);

View File

@ -2,6 +2,7 @@ using Fake.DependencyInjection;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
using SqlSugar;
namespace Oms.SqlSugarCore.Channels;
@ -64,4 +65,81 @@ public class KpiAreaRepository : OmsSqlSugarRepository<KpiArea>, IKpiAreaReposit
.OrderBy(x => x.Id)
.ToListAsync(cancellationToken);
}
public async Task<KpiAreaPublishRecord?> FindPublishByAreaIdAndContentHashAsync(
int areaId,
string contentHash,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var rows = await db.Queryable<KpiAreaPublishRecord>()
.Where(x => x.AreaId == areaId && x.ContentHash == contentHash)
.OrderBy(x => x.Id, OrderByType.Desc)
.Take(1)
.ToListAsync(cancellationToken);
return rows.FirstOrDefault();
}
public async Task<KpiAreaPublishRecord?> GetPublishByAreaIdAndVersionAsync(
int areaId,
string version,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(version))
{
return null;
}
var db = await GetDbContextAsync(cancellationToken);
var rows = await db.Queryable<KpiAreaPublishRecord>()
.Where(x => x.AreaId == areaId && x.Version == version)
.OrderBy(x => x.Id, OrderByType.Desc)
.Take(1)
.ToListAsync(cancellationToken);
return rows.FirstOrDefault();
}
public async Task<List<KpiAreaPublishRecord>> GetPublishHeadersByAreaIdAsync(
int areaId,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
return await db.Queryable<KpiAreaPublishRecord>()
.Where(x => x.AreaId == areaId)
.OrderBy(x => x.PublishTime, OrderByType.Desc)
.Select(x => new KpiAreaPublishRecord
{
Id = x.Id,
AreaId = x.AreaId,
AreaType = x.AreaType,
Version = x.Version,
Status = x.Status,
ContentHash = x.ContentHash,
PublishUser = x.PublishUser,
PublishTime = x.PublishTime,
Description = x.Description,
CreateTime = x.CreateTime
})
.ToListAsync(cancellationToken);
}
public async Task<KpiAreaPublishRecord?> GetPublishByIdAsync(
int id,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var rows = await db.Queryable<KpiAreaPublishRecord>()
.Where(x => x.Id == id)
.Take(1)
.ToListAsync(cancellationToken);
return rows.FirstOrDefault();
}
public async Task<KpiAreaPublishRecord> InsertPublishRecordAsync(
KpiAreaPublishRecord record,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
return await db.Insertable(record).ExecuteReturnEntityAsync();
}
}

View File

@ -1,3 +1,4 @@
using System.Linq.Expressions;
using FluentAssertions;
using Moq;
using Oms.Application.Dtos.Orders;
@ -10,7 +11,7 @@ namespace Oms.Tests;
public class RulesSnapshotHelperPublishTests
{
[Fact]
public async Task ApplyRulesToSnapshotAsync_ShouldEmbedBlacklistRemoteWeightAndRounding()
public async Task ApplyRulesToSnapshotAsync_ShouldPinBlacklistRemotePacksAndEmbedWeight()
{
var product = new SalesProduct
{
@ -22,10 +23,16 @@ public class RulesSnapshotHelperPublishTests
WeightRoundingId = 2
};
var areas = new Dictionary<int, KpiArea>
{
[10] = new() { Id = 10, Name = "黑名单区", AreaType = "blacklist" },
[20] = new() { Id = 20, Name = "偏远区", AreaType = "remote" }
};
var kpiMock = new Mock<IKpiAreaRepository>();
kpiMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny<System.Linq.Expressions.Expression<Func<KpiArea, bool>>>(), default))
.ReturnsAsync((System.Linq.Expressions.Expression<Func<KpiArea, bool>> _, CancellationToken _) =>
new KpiArea { Id = 10, Name = "黑名单区" });
kpiMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny<Expression<Func<KpiArea, bool>>>(), default))
.ReturnsAsync((Expression<Func<KpiArea, bool>> expr, CancellationToken _) =>
areas.Values.FirstOrDefault(expr.Compile()));
kpiMock.Setup(x => x.GetAllInfosByAreaIdAsync(10, default))
.ReturnsAsync(
[
@ -36,9 +43,17 @@ public class RulesSnapshotHelperPublishTests
[
new KpiAreaInfo { Id = 2, AreaId = 20, CountryCode = "US", LeftPostcode = "06000", RightPostcode = "06999" }
]);
kpiMock.Setup(x => x.FindPublishByAreaIdAndContentHashAsync(It.IsAny<int>(), It.IsAny<string>(), default))
.ReturnsAsync((KpiAreaPublishRecord?)null);
kpiMock.Setup(x => x.InsertPublishRecordAsync(It.IsAny<KpiAreaPublishRecord>(), default))
.ReturnsAsync((KpiAreaPublishRecord r, CancellationToken _) =>
{
r.Id = r.AreaId * 100;
return r;
});
var weightRuleMock = new Mock<IWeightRuleRepository>();
weightRuleMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny<System.Linq.Expressions.Expression<Func<WeightRule, bool>>>(), default))
weightRuleMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny<Expression<Func<WeightRule, bool>>>(), default))
.ReturnsAsync(new WeightRule { Id = 1, Name = "标准计重" });
weightRuleMock.Setup(x => x.GetAllInfosByRuleIdAsync(1, default))
.ReturnsAsync(
@ -54,7 +69,7 @@ public class RulesSnapshotHelperPublishTests
]);
var roundingMock = new Mock<IWeightRoundingRepository>();
roundingMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny<System.Linq.Expressions.Expression<Func<WeightRounding, bool>>>(), default))
roundingMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny<Expression<Func<WeightRounding, bool>>>(), default))
.ReturnsAsync(new WeightRounding { Id = 2, Name = "标准进位" });
roundingMock.Setup(x => x.GetAllInfosByRoundingIdAsync(2, default))
.ReturnsAsync(
@ -62,31 +77,30 @@ public class RulesSnapshotHelperPublishTests
new WeightRoundingInfo { Id = 1, RoundingId = 2, StartValue = 0, EndValue = 20, Public = "0.5" }
]);
var packService = new KpiAreaPackService(kpiMock.Object);
var snapshot = new SalesProductRulesPublishSnapshotDto();
await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
snapshot,
product,
kpiMock.Object,
weightRuleMock.Object,
roundingMock.Object);
roundingMock.Object,
packService,
"tester");
snapshot.BlacklistAreaId.Should().Be(10);
snapshot.BlacklistAreaInfos.Should().HaveCount(1);
snapshot.RemoteAreaId.Should().Be(20);
snapshot.RemoteAreaInfos.Should().HaveCount(1);
snapshot.WeightRuleId.Should().Be(1);
snapshot.BlacklistAreaRef.Should().NotBeNull();
snapshot.BlacklistAreaRef!.PackVersion.Should().NotBeNullOrWhiteSpace();
snapshot.BlacklistAreaInfos.Should().BeEmpty();
snapshot.RemoteAreaRef.Should().NotBeNull();
snapshot.RemoteAreaRef!.AreaId.Should().Be(20);
snapshot.WeightRuleInfos.Should().HaveCount(1);
snapshot.WeightRoundingId.Should().Be(2);
snapshot.WeightRoundingInfos.Should().HaveCount(1);
var json = OmsDocHelper.SerializeSnapshotJson(snapshot);
json.Should().Contain("PostcodeRange");
json.Should().Contain("(90000,90010]");
json.Should().Contain("WeightRuleInfos");
json.Should().Contain("WeightRoundingInfos");
json.Should().Contain("RemoteAreaInfos");
json.Should().NotContain("CreateTime");
json.Should().NotContain("UpdateTime");
json.Should().NotContain("LeftPostcode");
var json = OmsDocHelper.SerializeSnapshotJson(snapshot)!;
json.Should().Contain("blacklistAreaRef");
json.Should().Contain("remoteAreaRef");
json.Should().NotContain("blacklistAreaInfos");
json.Should().NotContain("remoteAreaInfos");
}
}