fix: sync-status 忽略价表明细数字 JSON 形态差异
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s

LIVE 侧 DeserializeJsonObject 常把客户组价收到 long,历史 Content 里常见 133.0/0.0000;JS 结构相等但 SHA256 不等导致误报待发布。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-07 19:52:04 +08:00
parent 7d14c0e5d9
commit 984799fcde
2 changed files with 225 additions and 8 deletions

View File

@ -1,6 +1,8 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.SystemAggregate.Consts;
@ -155,8 +157,9 @@ public static class OmsDocHelper
/// </summary>
public static string CanonicalizeSalesProductPriceSnapshotJson(object? snapshot)
{
var raw = SerializeSnapshotJson(snapshot) ?? string.Empty;
return SerializeSnapshotJson(DeserializePriceSnapshot(raw)) ?? string.Empty;
var dto = DeserializePriceSnapshot(SerializeSnapshotJson(snapshot));
StabilizePriceSnapshotDynamicJson(dto);
return SerializeSnapshotJson(dto) ?? string.Empty;
}
/// <summary>
@ -173,17 +176,19 @@ public static class OmsDocHelper
/// </summary>
public static string CanonicalizeChannelPriceSnapshotJson(object? snapshot)
{
var raw = SerializeSnapshotJson(snapshot) ?? string.Empty;
return SerializeSnapshotJson(DeserializeChannelPriceSnapshot(raw)) ?? string.Empty;
var dto = DeserializeChannelPriceSnapshot(SerializeSnapshotJson(snapshot));
StabilizeChannelPriceSnapshotDynamicJson(dto);
return SerializeSnapshotJson(dto) ?? string.Empty;
}
/// <summary>
/// 发布同步态哈希:canonical + 去掉 PackVersion/AreaName 等钉包展示字段。
/// 发布同步态哈希:canonical + 去掉 PackVersion/AreaName,并稳定动态 JSON 数字形态(133 vs 133.0、0 vs 0.0000)。
/// </summary>
public static string HashSalesProductPriceSnapshotForSync(object? snapshot)
{
var dto = DeserializePriceSnapshot(SerializeSnapshotJson(snapshot));
PublishSnapshotNormalizer.StripVolatilePackPinsForSyncCompare(dto);
StabilizePriceSnapshotDynamicJson(dto);
return SerializeSnapshotJson(dto) ?? string.Empty;
}
@ -198,9 +203,99 @@ public static class OmsDocHelper
{
var dto = DeserializeChannelPriceSnapshot(SerializeSnapshotJson(snapshot));
PublishSnapshotNormalizer.StripVolatilePackPinsForSyncCompare(dto);
StabilizeChannelPriceSnapshotDynamicJson(dto);
return SerializeSnapshotJson(dto) ?? string.Empty;
}
/// <summary>
/// 把 FactorValuesJson / CustomerGroupValues / CalculatedValue 收到同一数字口径,避免 sync-status 误报待发布。
/// </summary>
private static void StabilizePriceSnapshotDynamicJson(SalesProductPricePublishSnapshotDto? snapshot)
{
if (snapshot?.Prices == null)
{
return;
}
foreach (var price in snapshot.Prices)
{
StabilizePriceDetails(price.Details);
}
}
private static void StabilizeChannelPriceSnapshotDynamicJson(ServiceChannelPricePublishSnapshotDto? snapshot)
{
if (snapshot?.Prices == null)
{
return;
}
foreach (var price in snapshot.Prices)
{
StabilizePriceDetails(price.Details);
}
}
private static void StabilizePriceDetails(List<ChannelPriceDetailOutput>? details)
{
if (details == null)
{
return;
}
foreach (var detail in details)
{
detail.FactorValuesJson = StabilizeDynamicJsonValue(detail.FactorValuesJson);
detail.CustomerGroupValues = StabilizeDynamicJsonValue(detail.CustomerGroupValues);
if (detail.CalculatedValue is { } calculated)
{
detail.CalculatedValue = StabilizeDecimal(calculated);
}
}
}
/// <summary>
/// JsonElement / Dictionary 等动态 JSON 统一走 ConvertJsonElement,整数形态的小数收成 long。
/// </summary>
private static object? StabilizeDynamicJsonValue(object? value)
{
if (value is null)
{
return null;
}
string? json = value switch
{
JsonElement element => element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined
? null
: element.GetRawText(),
JsonNode node => node.ToJsonString(),
string text => string.IsNullOrWhiteSpace(text) ? null : text,
_ => SerializeJson(value)
};
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
using var document = JsonDocument.Parse(json);
return ConvertJsonElement(document.RootElement);
}
private static decimal StabilizeDecimal(decimal value)
{
// 去掉尾随 0:0.0000 → 0,133.0 → 133(再由序列化写成整型 JSON)
var normalized = decimal.Parse(value.ToString("G29"), CultureInfo.InvariantCulture);
if (normalized == decimal.Truncate(normalized)
&& normalized >= long.MinValue
&& normalized <= long.MaxValue)
{
return normalized;
}
return normalized;
}
/// <summary>
/// 解析渠道买价发布 Content,供列表/对比 API 使用(兼容旧快照审计字段)。
/// </summary>
@ -227,15 +322,34 @@ public static class OmsDocHelper
.Select(ConvertJsonElement)
.ToList(),
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var integer)
? integer
: element.GetDecimal(),
JsonValueKind.Number => ConvertJsonNumber(element),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null or JsonValueKind.Undefined => null,
_ => element.GetRawText()
};
/// <summary>
/// 数字稳定化:能无损落成整型的一律 long,避免 133 / 133.0 导致发布 Content 与 LIVE 哈希不一致。
/// </summary>
private static object ConvertJsonNumber(JsonElement element)
{
if (element.TryGetInt64(out var integer))
{
return integer;
}
var decimalValue = StabilizeDecimal(element.GetDecimal());
if (decimalValue == decimal.Truncate(decimalValue)
&& decimalValue >= long.MinValue
&& decimalValue <= long.MaxValue)
{
return (long)decimalValue;
}
return decimalValue;
}
/// <summary>
/// 反序列化 JSON 字符串数组。
/// </summary>

View File

@ -107,4 +107,107 @@ public class OmsDocHelperTests
typeName.Should().Be("中转仓");
}
[Fact]
public void HashSalesProductPriceSnapshotForSync_ShouldIgnoreNumberFormatNoise()
{
// LIVE:DeserializeJsonObject 常把价收到 long;历史 Content 里常见 133.0 / 0.0000
var live = """
{
"salesProductId": 8,
"priceAreaId": 3,
"priceAreaName": "美国分区",
"priceAreaRefs": [
{
"areaId": 3,
"areaName": "美国分区",
"packVersion": "20261007164410403",
"contentHash": "e14ccf7436f001743ba9d45e0ec95f72285abd70b161fe91591766b909cefc80"
}
],
"prices": [
{
"id": 13,
"serialNumber": 1,
"status": 2,
"priceName": "运费",
"costItem": "freight",
"costUnit": "kg",
"currency": "CNY",
"chargingCondition": "无",
"factors": [],
"details": [
{
"id": 1511,
"priceId": 13,
"factorValuesJson": { "仓库": "2", "重量": "(0-0.5]" },
"calculationFormula": "total",
"calculatedValue": 0,
"customerGroupValues": { "全部客户": 133 }
}
]
}
]
}
""";
var online = """
{
"salesProductId": 8,
"priceAreaId": 3,
"priceAreaName": "美国分区",
"priceAreaRefs": [
{
"areaId": 3,
"areaName": "美国分区",
"packVersion": "20261007164410403",
"contentHash": "e14ccf7436f001743ba9d45e0ec95f72285abd70b161fe91591766b909cefc80"
}
],
"prices": [
{
"id": 13,
"serialNumber": 1,
"status": 2,
"priceName": "运费",
"costItem": "freight",
"costUnit": "kg",
"currency": "CNY",
"chargingCondition": "无",
"factors": [],
"details": [
{
"id": 1511,
"priceId": 13,
"factorValuesJson": { "仓库": "2", "重量": "(0-0.5]" },
"calculationFormula": "total",
"calculatedValue": 0.0000,
"customerGroupValues": { "全部客户": 133.0 }
}
]
}
]
}
""";
var liveHash = OmsDocHelper.HashSalesProductPriceSnapshotForSync(
OmsDocHelper.DeserializePriceSnapshot(live));
var onlineHash = OmsDocHelper.HashSalesProductPriceSnapshotForSync(
OmsDocHelper.DeserializePriceSnapshot(online));
liveHash.Should().Be(onlineHash);
liveHash.Should().Contain(":133}");
liveHash.Should().Contain("\"calculatedValue\":0");
liveHash.Should().NotContain("133.0");
liveHash.Should().NotContain("0.0000");
}
[Fact]
public void DeserializeJsonObject_ShouldCoerceIntegerValuedDecimalsToLong()
{
var result = OmsDocHelper.DeserializeJsonObject("""{"全部客户":133.0,"VIP":12.5}""");
var map = result.Should().BeOfType<Dictionary<string, object?>>().Subject;
map["全部客户"].Should().Be(133L);
map["VIP"].Should().Be(12.5m);
}
}