feat: 规则发布预览接口并抽取快照组装逻辑

新增 GET publish/preview 返回含黑名单/偏远/计重/进位明细的完整 rules 快照,便于核对 Content。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-26 13:48:07 +08:00
parent 2c69e8e5b8
commit d8a98fbbc8
3 changed files with 159 additions and 45 deletions

View File

@ -155,56 +155,70 @@ public partial class SalesProductService
}; };
} }
/// <summary>
/// 组装规则发布快照(不落库),含黑名单/偏远/计重/进位明细。
/// </summary>
private async Task<SalesProductRulesPublishSnapshotDto> BuildRulesPublishSnapshotAsync(int salesProductId)
{
var product = await GetProductOrThrowAsync(salesProductId);
var rulesSnapshot = new SalesProductRulesPublishSnapshot
{
Product = MapProductToRulesSaveInput(product),
Providers = (await GetAllocationCoreAsync(salesProductId)).Providers
};
await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
rulesSnapshot,
product,
KpiAreaRepository,
WeightRuleRepository,
WeightRoundingRepository);
return rulesSnapshot;
}
/// <summary>
/// 组装价格发布快照(不落库)。
/// </summary>
private async Task<SalesProductPricePublishSnapshotDto> BuildPricePublishSnapshotAsync(int salesProductId)
{
var product = await GetProductOrThrowAsync(salesProductId);
var priceSnapshot = new SalesProductPricePublishSnapshot
{
SalesProductId = salesProductId,
Prices = await GetPriceOutputsAsync(salesProductId)
};
await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(priceSnapshot, product, KpiAreaRepository);
return priceSnapshot;
}
private static SalesProductSaveInput MapProductToRulesSaveInput(SalesProduct product) =>
new()
{
Id = product.Id,
ProductCode = product.ProductCode,
ProductName = product.ProductName,
ProductGroup = product.ProductGroup,
MinTimeliness = product.MinTimeliness,
MaxTimeliness = product.MaxTimeliness,
CustomerWaybill = product.CustomerWaybill,
BlacklistAreaId = product.BlacklistAreaId,
RemoteAreaId = product.RemoteAreaId,
PriceAreaId = product.PriceAreaId,
WeightRuleId = product.WeightRuleId,
WeightRoundingId = product.WeightRoundingId,
ProductDescription = product.ProductDescription,
PreStorageScript = product.PreStorageScript,
IsActive = product.IsActive,
Scheme = product.Scheme
};
/// <summary> /// <summary>
/// 为当前配置生成发布快照。 /// 为当前配置生成发布快照。
/// </summary> /// </summary>
private async Task CreatePublishRecordAsync(int salesProductId, string operatorType, string? description) private async Task CreatePublishRecordAsync(int salesProductId, string operatorType, string? description)
{ {
var product = await GetProductOrThrowAsync(salesProductId); object snapshot = operatorType == PriceOperatorType
object snapshot; ? await BuildPricePublishSnapshotAsync(salesProductId)
if (operatorType == PriceOperatorType) : await BuildRulesPublishSnapshotAsync(salesProductId);
{
var priceSnapshot = new SalesProductPricePublishSnapshot
{
SalesProductId = salesProductId,
Prices = await GetPriceOutputsAsync(salesProductId)
};
await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(priceSnapshot, product, KpiAreaRepository);
snapshot = priceSnapshot;
}
else
{
var rulesSnapshot = new SalesProductRulesPublishSnapshot
{
Product = new SalesProductSaveInput
{
Id = product.Id,
ProductCode = product.ProductCode,
ProductName = product.ProductName,
ProductGroup = product.ProductGroup,
MinTimeliness = product.MinTimeliness,
MaxTimeliness = product.MaxTimeliness,
CustomerWaybill = product.CustomerWaybill,
BlacklistAreaId = product.BlacklistAreaId,
RemoteAreaId = product.RemoteAreaId,
PriceAreaId = product.PriceAreaId,
WeightRuleId = product.WeightRuleId,
WeightRoundingId = product.WeightRoundingId,
ProductDescription = product.ProductDescription,
PreStorageScript = product.PreStorageScript,
IsActive = product.IsActive,
Scheme = product.Scheme
},
Providers = (await GetAllocationCoreAsync(salesProductId)).Providers
};
await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
rulesSnapshot,
product,
KpiAreaRepository,
WeightRuleRepository,
WeightRoundingRepository);
snapshot = rulesSnapshot;
}
// Content 中保存完整快照,回滚时直接从快照恢复,避免依赖当前库内状态。 // Content 中保存完整快照,回滚时直接从快照恢复,避免依赖当前库内状态。
await SalesProductRepository.InsertPublishRecordAsync(new SalesProductPublishRecord await SalesProductRepository.InsertPublishRecordAsync(new SalesProductPublishRecord

View File

@ -408,6 +408,18 @@ public partial class SalesProductService : ApplicationService
return GetPublishListAsync(input, RulesOperatorType); return GetPublishListAsync(input, RulesOperatorType);
} }
/// <summary>
/// 预览即将发布的快照 JSON(不落库),用于核对黑名单/计重等是否已固化进 Content。
/// </summary>
[HttpGet("/channel/sales-product/publish/preview")]
public async Task<object> PreviewPublishSnapshotAsync([FromQuery] int salesProductId, [FromQuery] string operatorType)
{
var normalized = NormalizeOperatorType(operatorType);
return normalized == PriceOperatorType
? await BuildPricePublishSnapshotAsync(salesProductId)
: await BuildRulesPublishSnapshotAsync(salesProductId);
}
/// <summary> /// <summary>
/// 执行销售产品发布。 /// 执行销售产品发布。
/// </summary> /// </summary>

View File

@ -0,0 +1,88 @@
using FluentAssertions;
using Moq;
using Oms.Application.Dtos.Orders;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
namespace Oms.Tests;
public class RulesSnapshotHelperPublishTests
{
[Fact]
public async Task ApplyRulesToSnapshotAsync_ShouldEmbedBlacklistRemoteWeightAndRounding()
{
var product = new SalesProduct
{
Id = 1,
ProductCode = "test01",
BlacklistAreaId = 10,
RemoteAreaId = 20,
WeightRuleId = 1,
WeightRoundingId = 2
};
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.GetAllInfosByAreaIdAsync(10, default))
.ReturnsAsync(
[
new KpiAreaInfo { Id = 1, AreaId = 10, CountryCode = "US", LeftPostcode = "90000", RightPostcode = "90010" }
]);
kpiMock.Setup(x => x.GetAllInfosByAreaIdAsync(20, default))
.ReturnsAsync(
[
new KpiAreaInfo { Id = 2, AreaId = 20, CountryCode = "US", LeftPostcode = "06000", RightPostcode = "06999" }
]);
var weightRuleMock = new Mock<IWeightRuleRepository>();
weightRuleMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny<System.Linq.Expressions.Expression<Func<WeightRule, bool>>>(), default))
.ReturnsAsync(new WeightRule { Id = 1, Name = "标准计重" });
weightRuleMock.Setup(x => x.GetAllInfosByRuleIdAsync(1, default))
.ReturnsAsync(
[
new WeightRuleInfo
{
Id = 1,
RuleId = 1,
Condition = "gw>vw",
BillableWeight = "gw",
VolumetricFactor = "6000"
}
]);
var roundingMock = new Mock<IWeightRoundingRepository>();
roundingMock.Setup(x => x.FirstOrDefaultAsync(It.IsAny<System.Linq.Expressions.Expression<Func<WeightRounding, bool>>>(), default))
.ReturnsAsync(new WeightRounding { Id = 2, Name = "标准进位" });
roundingMock.Setup(x => x.GetAllInfosByRoundingIdAsync(2, default))
.ReturnsAsync(
[
new WeightRoundingInfo { Id = 1, RoundingId = 2, StartValue = 0, EndValue = 20, Public = "0.5" }
]);
var snapshot = new SalesProductRulesPublishSnapshotDto();
await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
snapshot,
product,
kpiMock.Object,
weightRuleMock.Object,
roundingMock.Object);
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.WeightRuleInfos.Should().HaveCount(1);
snapshot.WeightRoundingId.Should().Be(2);
snapshot.WeightRoundingInfos.Should().HaveCount(1);
var json = OmsDocHelper.SerializeJson(snapshot);
json.Should().Contain("BlacklistAreaInfos");
json.Should().Contain("WeightRuleInfos");
json.Should().Contain("WeightRoundingInfos");
json.Should().Contain("RemoteAreaInfos");
}
}