Merge pull request '订阅轨迹中心轨迹状态,变更运单状态' (#15) from test into master
All checks were successful
Build and Deploy / deploy (push) Successful in 1m56s

Reviewed-on: #15
This commit is contained in:
zzh 2026-10-27 17:50:21 +08:00
commit cb60028538
17 changed files with 1048 additions and 140 deletions

View File

@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Oms.Application.Messaging.TrackChange;
namespace Oms.Application.Integrations.SmallBagTrack; namespace Oms.Application.Integrations.SmallBagTrack;
@ -8,12 +9,16 @@ public static class SmallBagTrackServiceExtensions
public static IServiceCollection AddOmsSmallBagTrack(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddOmsSmallBagTrack(this IServiceCollection services, IConfiguration configuration)
{ {
services.Configure<SmallBagTrackOptions>(configuration.GetSection(SmallBagTrackOptions.SectionName)); services.Configure<SmallBagTrackOptions>(configuration.GetSection(SmallBagTrackOptions.SectionName));
services.Configure<TrackChangeSubscribeOptions>(
configuration.GetSection(TrackChangeSubscribeOptions.SectionName));
services.AddHttpClient(SmallBagTrackOrderClient.HttpClientName); services.AddHttpClient(SmallBagTrackOrderClient.HttpClientName);
services.AddScoped<ISmallBagTrackConfigProvider, SmallBagTrackConfigProvider>(); services.AddScoped<ISmallBagTrackConfigProvider, SmallBagTrackConfigProvider>();
services.AddScoped<ISmallBagTrackUpsertRequestBuilder, SmallBagTrackUpsertRequestBuilder>(); services.AddScoped<ISmallBagTrackUpsertRequestBuilder, SmallBagTrackUpsertRequestBuilder>();
services.AddScoped<ISmallBagTrackOrderClient, SmallBagTrackOrderClient>(); services.AddScoped<ISmallBagTrackOrderClient, SmallBagTrackOrderClient>();
services.AddScoped<IPickupBigBagTrackSyncer, PickupBigBagTrackSyncer>(); services.AddScoped<IPickupBigBagTrackSyncer, PickupBigBagTrackSyncer>();
services.AddSingleton<ISmallBagTrackOrderNotifier, SmallBagTrackOrderNotifier>(); services.AddSingleton<ISmallBagTrackOrderNotifier, SmallBagTrackOrderNotifier>();
services.AddScoped<ITrackChangeOrderStatusApplier, TrackChangeOrderStatusApplier>();
services.AddHostedService<TrackChangeSubscriberHostedService>();
return services; return services;
} }
} }

View File

@ -0,0 +1,12 @@
namespace Oms.Application.Messaging.TrackChange;
/// <summary>
/// 根据轨迹变更事件同步 OMS 出库后订单状态。
/// </summary>
public interface ITrackChangeOrderStatusApplier
{
/// <summary>
/// 处理一条 TrackChange 消息;业务跳过不抛异常。
/// </summary>
Task ApplyAsync(TrackChangeEto eto, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,65 @@
namespace Oms.Application.Messaging.TrackChange;
/// <summary>
/// 轨迹中心轨迹变更广播(RabbitMQ topic:track.on_track_change_ex / TrackChange.{AppKey})。
/// 字段与 TrackCenter.Application.Events.TrackChangeEto 对齐(Newtonsoft 默认 PascalCase)。
/// </summary>
public sealed class TrackChangeEto
{
/// <summary>轨迹唯一 id;操作失败时可能为 null。</summary>
public string? TrackEsId { get; set; }
/// <summary>操作类型:1 新增,2 删除,3 修改。</summary>
public int Option { get; set; }
/// <summary>操作是否成功。</summary>
public bool IsSuccess { get; set; }
/// <summary>失败原因。</summary>
public string? FailedReason { get; set; }
/// <summary>应用 AppKey。</summary>
public string? AppKey { get; set; }
/// <summary>单号(OMS 侧对应运单号 ServiceNo)。</summary>
public string? OrderNo { get; set; }
/// <summary>客户标识。</summary>
public string? Customer { get; set; }
/// <summary>操作人。</summary>
public string? OperationMan { get; set; }
/// <summary>轨迹内容代码。</summary>
public string? TrackCode { get; set; }
/// <summary>轨迹内容。</summary>
public string? Content { get; set; }
/// <summary>轨迹内容英文。</summary>
public string? EnStatus { get; set; }
/// <summary>轨迹状态代码(如 D03 / D09 / D11)。</summary>
public string? TrackStatusCode { get; set; }
/// <summary>轨迹发生地。</summary>
public string? TrackPlace { get; set; }
/// <summary>轨迹发生时间。</summary>
public DateTime TrackTime { get; set; }
/// <summary>轨迹发生地时区。</summary>
public float TrackTimeZone { get; set; }
/// <summary>是否对外。</summary>
public bool IsExternal { get; set; }
/// <summary>数据来源:1 外部服务商,2 内部系统,3 人工。</summary>
public int Source { get; set; }
/// <summary>轨迹备注。</summary>
public string? Remarks { get; set; }
/// <summary>扩展信息。</summary>
public string? Extra { get; set; }
}

View File

@ -0,0 +1,168 @@
using Fake.DependencyInjection;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders;
using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Messaging.TrackChange;
/// <summary>
/// 将轨迹状态 D03/D09/D11 同步到运单订单状态(运输中 / 派送失败 / 派送成功)。
/// 规则对齐手动「出库后状态校正」:仅出库后四态可改;派送成功只进不出。
/// </summary>
public sealed class TrackChangeOrderStatusApplier(
IOrderRepository orderRepository,
OrderOperationLogWriter operationLogWriter,
ReferralCommissionManager referralCommissionManager,
MarketingCouponManager marketingCouponManager,
IUnitOfWorkManager unitOfWorkManager,
ILogger<TrackChangeOrderStatusApplier> logger) : ITrackChangeOrderStatusApplier, IScopedDependency
{
private static readonly HashSet<string> PostShipStatuses = new(StringComparer.OrdinalIgnoreCase)
{
OrderStatusCodes.PendingOnline,
OrderStatusCodes.Shipping,
OrderStatusCodes.DeliverySuccess,
OrderStatusCodes.DeliveryFailed
};
public async Task ApplyAsync(TrackChangeEto eto, CancellationToken cancellationToken = default)
{
if (eto == null)
{
return;
}
if (!eto.IsSuccess)
{
logger.LogDebug(
"TrackChange skip failed event OrderNo={OrderNo} Reason={Reason}",
eto.OrderNo,
eto.FailedReason);
return;
}
// 删除轨迹不回退 OMS 状态
if (eto.Option == 2)
{
logger.LogDebug("TrackChange skip delete Option=2 OrderNo={OrderNo}", eto.OrderNo);
return;
}
var targetStatus = TrackStatusOrderStatusMapper.TryMapToOrderStatus(eto.TrackStatusCode);
if (targetStatus == null)
{
logger.LogDebug(
"TrackChange skip unmapped status OrderNo={OrderNo} TrackStatusCode={TrackStatusCode}",
eto.OrderNo,
eto.TrackStatusCode);
return;
}
var orderNo = eto.OrderNo?.Trim();
if (string.IsNullOrWhiteSpace(orderNo))
{
logger.LogWarning("TrackChange skip empty OrderNo TrackStatusCode={TrackStatusCode}", eto.TrackStatusCode);
return;
}
using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional());
var order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken);
if (order == null)
{
logger.LogWarning(
"TrackChange order not found ServiceNo={OrderNo} TrackStatusCode={TrackStatusCode}",
orderNo,
eto.TrackStatusCode);
await unitOfWork.CompleteAsync(cancellationToken);
return;
}
var fromStatus = order.OrderStatus?.Trim() ?? string.Empty;
if (!IsPostShipStatus(fromStatus))
{
logger.LogInformation(
"TrackChange skip non post-ship status OrderId={OrderId} ServiceNo={ServiceNo} From={From} TrackStatusCode={TrackStatusCode}",
order.Id,
order.ServiceNo,
fromStatus,
eto.TrackStatusCode);
await unitOfWork.CompleteAsync(cancellationToken);
return;
}
if (string.Equals(fromStatus, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation(
"TrackChange skip deliverySuccess terminal OrderId={OrderId} ServiceNo={ServiceNo} TrackStatusCode={TrackStatusCode}",
order.Id,
order.ServiceNo,
eto.TrackStatusCode);
await unitOfWork.CompleteAsync(cancellationToken);
return;
}
if (string.Equals(fromStatus, targetStatus, StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug(
"TrackChange idempotent skip OrderId={OrderId} ServiceNo={ServiceNo} Status={Status}",
order.Id,
order.ServiceNo,
targetStatus);
await unitOfWork.CompleteAsync(cancellationToken);
return;
}
var beforeSnap = OrderOperationLogSnapshot.CaptureOrder(order);
order.OrderStatus = targetStatus;
order.UpdateTime = DateTime.Now;
if (string.Equals(targetStatus, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase))
{
order.SignTime = DateTime.Now;
}
await orderRepository.UpdateAsync(order, cancellationToken: cancellationToken);
if (string.Equals(targetStatus, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase))
{
await referralCommissionManager.OnOrderDeliverySuccessAsync(order.Id, cancellationToken);
// 重新加载,避免营销侧依赖最新字段
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;
await marketingCouponManager.OnFirstDeliverySuccessAsync(order, cancellationToken);
}
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;
var afterSnap = OrderOperationLogSnapshot.CaptureOrder(order);
var fromName = OmsDocHelper.GetOrderStatusName(fromStatus) ?? fromStatus;
var toName = OmsDocHelper.GetOrderStatusName(targetStatus) ?? targetStatus;
var trackHint = string.IsNullOrWhiteSpace(eto.Content)
? eto.TrackStatusCode
: $"{eto.TrackStatusCode}/{eto.Content}";
await operationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.TrackStatusSync,
beforeSnap,
afterSnap,
$"{fromName} → {toName};轨迹同步 {trackHint}",
operatorId: 0,
operatorName: string.IsNullOrWhiteSpace(eto.OperationMan) ? "轨迹中心" : eto.OperationMan.Trim(),
operatorType: "system",
source: "trackCenter");
await unitOfWork.CompleteAsync(cancellationToken);
logger.LogInformation(
"TrackChange applied OrderId={OrderId} ServiceNo={ServiceNo} {From} → {To} TrackStatusCode={TrackStatusCode}",
order.Id,
order.ServiceNo,
fromStatus,
targetStatus,
eto.TrackStatusCode);
}
private static bool IsPostShipStatus(string? status) =>
!string.IsNullOrWhiteSpace(status) && PostShipStatuses.Contains(status.Trim());
}

View File

@ -0,0 +1,43 @@
namespace Oms.Application.Messaging.TrackChange;
/// <summary>
/// 订阅轨迹中心 TrackChange 广播的配置(Consul / 配置:Oms:TrackChangeSubscribe)。
/// </summary>
public class TrackChangeSubscribeOptions
{
public const string SectionName = "Oms:TrackChangeSubscribe";
/// <summary>是否启用订阅;默认 true。</summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// 轨迹中心广播交换器;须与 TrackCenter GlobalConst.TrackChangeExchange 一致。
/// </summary>
public string Exchange { get; set; } = "track.on_track_change_ex";
/// <summary>
/// 绑定路由键模板,{AppKey} 会替换为轨迹服务 AppKey。
/// </summary>
public string RoutingKeyTemplate { get; set; } = "TrackChange.{AppKey}";
/// <summary>
/// 本地队列名模板;{AppKey} 会替换。为空时使用 oms.track_change.{AppKey}。
/// </summary>
public string? QueueNameTemplate { get; set; } = "oms.track_change.{AppKey}";
/// <summary>
/// Fake.RabbitMQ 连接名;为空则使用 RabbitMqEventBusOptions.ConnectionName。
/// </summary>
public string? ConnectionName { get; set; }
/// <summary>消费预取数量。</summary>
public ushort PrefetchCount { get; set; } = 10;
/// <summary>
/// 覆盖轨迹 AppKey(一般留空,走 Oms:TrackService / 系统其他配置 track_service)。
/// </summary>
public string? AppKey { get; set; }
/// <summary>处理失败时是否重新入队。</summary>
public bool FailedRequeue { get; set; } = true;
}

View File

@ -0,0 +1,202 @@
using System.Text;
using Fake.EventBus.RabbitMQ;
using Fake.RabbitMQ;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Oms.Application.Integrations.SmallBagTrack;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
namespace Oms.Application.Messaging.TrackChange;
/// <summary>
/// 订阅轨迹中心 TrackChange 广播,驱动 OMS 出库后订单状态变更。
/// </summary>
public sealed class TrackChangeSubscriberHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IRabbitMqConnectionPool _connectionPool;
private readonly IOptions<TrackChangeSubscribeOptions> _subscribeOptions;
private readonly IOptions<RabbitMqEventBusOptions> _eventBusOptions;
private readonly ILogger<TrackChangeSubscriberHostedService> _logger;
private IModel? _channel;
public TrackChangeSubscriberHostedService(
IServiceScopeFactory scopeFactory,
IRabbitMqConnectionPool connectionPool,
IOptions<TrackChangeSubscribeOptions> subscribeOptions,
IOptions<RabbitMqEventBusOptions> eventBusOptions,
ILogger<TrackChangeSubscriberHostedService> logger)
{
_scopeFactory = scopeFactory;
_connectionPool = connectionPool;
_subscribeOptions = subscribeOptions;
_eventBusOptions = eventBusOptions;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var options = _subscribeOptions.Value;
if (!options.Enabled)
{
_logger.LogInformation("TrackChange subscribe disabled");
return;
}
string? appKey;
try
{
appKey = await ResolveAppKeyAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "TrackChange subscribe failed to resolve AppKey; consumer not started");
return;
}
if (string.IsNullOrWhiteSpace(appKey))
{
_logger.LogWarning("TrackChange subscribe skipped: AppKey empty (configure Oms:TrackService or Oms:TrackChangeSubscribe:AppKey)");
return;
}
var exchange = string.IsNullOrWhiteSpace(options.Exchange)
? "track.on_track_change_ex"
: options.Exchange.Trim();
var routingKey = (options.RoutingKeyTemplate ?? "TrackChange.{AppKey}")
.Replace("{AppKey}", appKey, StringComparison.OrdinalIgnoreCase);
var queueName = string.IsNullOrWhiteSpace(options.QueueNameTemplate)
? $"oms.track_change.{appKey}"
: options.QueueNameTemplate.Replace("{AppKey}", appKey, StringComparison.OrdinalIgnoreCase);
var connectionName = string.IsNullOrWhiteSpace(options.ConnectionName)
? _eventBusOptions.Value.ConnectionName
: options.ConnectionName;
try
{
// Connection 由 Fake 连接池托管,禁止 Dispose
var connection = _connectionPool.Get(connectionName);
_channel = connection.CreateModel();
_channel.ExchangeDeclare(exchange, ExchangeType.Topic, durable: true, autoDelete: false);
_channel.QueueDeclare(queueName, durable: true, exclusive: false, autoDelete: false);
_channel.QueueBind(queueName, exchange, routingKey);
if (options.PrefetchCount > 0)
{
_channel.BasicQos(0, options.PrefetchCount, false);
}
var consumer = new AsyncEventingBasicConsumer(_channel);
consumer.Received += OnReceivedAsync;
_channel.BasicConsume(queue: queueName, autoAck: false, consumer: consumer);
_logger.LogInformation(
"TrackChange subscribe started Exchange={Exchange} RoutingKey={RoutingKey} Queue={Queue}",
exchange,
routingKey,
queueName);
}
catch (Exception ex)
{
_logger.LogError(ex, "TrackChange subscribe failed to start");
DisposeChannel();
return;
}
try
{
await Task.Delay(Timeout.Infinite, stoppingToken);
}
catch (OperationCanceledException)
{
// shutdown
}
finally
{
DisposeChannel();
_logger.LogInformation("TrackChange subscribe stopped");
}
}
private async Task OnReceivedAsync(object sender, BasicDeliverEventArgs ea)
{
var channel = _channel;
if (channel == null || !channel.IsOpen)
{
return;
}
var options = _subscribeOptions.Value;
try
{
var json = Encoding.UTF8.GetString(ea.Body.Span);
var eto = JsonConvert.DeserializeObject<TrackChangeEto>(json);
if (eto == null)
{
_logger.LogWarning("TrackChange deserialize null body");
channel.BasicAck(ea.DeliveryTag, false);
return;
}
await using var scope = _scopeFactory.CreateAsyncScope();
var applier = scope.ServiceProvider.GetRequiredService<ITrackChangeOrderStatusApplier>();
await applier.ApplyAsync(eto);
channel.BasicAck(ea.DeliveryTag, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "TrackChange handle failed DeliveryTag={DeliveryTag}", ea.DeliveryTag);
try
{
channel.BasicNack(ea.DeliveryTag, false, options.FailedRequeue);
}
catch (Exception nackEx)
{
_logger.LogError(nackEx, "TrackChange Nack failed DeliveryTag={DeliveryTag}", ea.DeliveryTag);
}
}
}
private async Task<string?> ResolveAppKeyAsync(CancellationToken cancellationToken)
{
var overrideKey = _subscribeOptions.Value.AppKey?.Trim();
if (!string.IsNullOrWhiteSpace(overrideKey))
{
return overrideKey;
}
await using var scope = _scopeFactory.CreateAsyncScope();
var trackConfig = scope.ServiceProvider.GetRequiredService<ISmallBagTrackConfigProvider>();
var settings = await trackConfig.GetAsync(cancellationToken);
return string.IsNullOrWhiteSpace(settings.AppKey) ? null : settings.AppKey.Trim();
}
private void DisposeChannel()
{
try
{
_channel?.Close();
_channel?.Dispose();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "TrackChange channel dispose ignored");
}
finally
{
_channel = null;
}
}
public override void Dispose()
{
DisposeChannel();
base.Dispose();
}
}

View File

@ -0,0 +1,38 @@
using Oms.Domain.OrdersAggregate.Consts;
namespace Oms.Application.Messaging.TrackChange;
/// <summary>
/// 轨迹状态码 → OMS 出库后订单状态。
/// </summary>
public static class TrackStatusOrderStatusMapper
{
/// <summary>运输中。</summary>
public const string InTransit = "D03";
/// <summary>派送失败。</summary>
public const string DeliveryFailed = "D09";
/// <summary>派送成功。</summary>
public const string DeliverySuccess = "D11";
/// <summary>
/// 将轨迹状态码映射为 OMS OrderStatus;未映射返回 null。
/// </summary>
public static string? TryMapToOrderStatus(string? trackStatusCode)
{
var code = trackStatusCode?.Trim();
if (string.IsNullOrWhiteSpace(code))
{
return null;
}
return code.ToUpperInvariant() switch
{
InTransit => OrderStatusCodes.Shipping,
DeliveryFailed => OrderStatusCodes.DeliveryFailed,
DeliverySuccess => OrderStatusCodes.DeliverySuccess,
_ => null
};
}
}

View File

@ -3,6 +3,7 @@ using Fake.Application;
using Fake.UnitOfWork; using Fake.UnitOfWork;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -44,6 +45,19 @@ public partial class OrderService
private static readonly ConcurrentDictionary<int, byte> InboundBillingInFlight = new(); private static readonly ConcurrentDictionary<int, byte> InboundBillingInFlight = new();
/// <summary>
/// 已发布规则+价格快照绑定缓存(按 productId+version);LIVE 不入缓存。
/// </summary>
private static readonly MemoryCache SnapshotBindingCache = new(new MemoryCacheOptions
{
SizeLimit = 256
});
/// <summary>
/// 计重进位回读 DB 时串行化,避免查价并行路径共用 SqlSugar 连接。
/// </summary>
private static readonly SemaphoreSlim WeightRoundingDbGate = new(1, 1);
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private ICustomerExportQualificationRepository ExportQualificationRepository => private ICustomerExportQualificationRepository ExportQualificationRepository =>
LazyServiceProvider.GetRequiredService<ICustomerExportQualificationRepository>(); LazyServiceProvider.GetRequiredService<ICustomerExportQualificationRepository>();
@ -553,6 +567,30 @@ public partial class OrderService
{ {
var rulesRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, RulesOperatorType, bindTime); var rulesRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, RulesOperatorType, bindTime);
var priceRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, PriceOperatorType, bindTime); var priceRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, PriceOperatorType, bindTime);
return await BuildSnapshotBindingAsync(product, bindTime, rulesRecord, priceRecord);
}
/// <summary>
/// 由已查到的发布记录组装绑定;已发布版本命中进程内 MemoryCache。
/// </summary>
private async Task<ProductSnapshotBinding> BuildSnapshotBindingAsync(
SalesProduct product,
DateTime bindTime,
SalesProductPublishRecord? rulesRecord,
SalesProductPublishRecord? priceRecord)
{
var canCache = rulesRecord != null
&& priceRecord != null
&& !string.IsNullOrWhiteSpace(rulesRecord.Version)
&& !string.IsNullOrWhiteSpace(priceRecord.Version);
if (canCache)
{
var cacheKey = BuildSnapshotBindingCacheKey(product.Id, rulesRecord!.Version, priceRecord!.Version);
if (SnapshotBindingCache.TryGetValue(cacheKey, out ProductSnapshotBinding? cached) && cached != null)
{
return cached;
}
}
SalesProductRulesPublishSnapshotDto rulesSnapshot; SalesProductRulesPublishSnapshotDto rulesSnapshot;
if (rulesRecord == null) if (rulesRecord == null)
@ -599,9 +637,25 @@ public partial class OrderService
Price = priceSnapshot Price = priceSnapshot
}; };
await KpiAreaPackService.HydrateBindingAsync(binding); await KpiAreaPackService.HydrateBindingAsync(binding);
if (canCache)
{
SnapshotBindingCache.Set(
BuildSnapshotBindingCacheKey(product.Id, rulesRecord!.Version, priceRecord!.Version),
binding,
new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = TimeSpan.FromMinutes(10)
});
}
return binding; return binding;
} }
private static string BuildSnapshotBindingCacheKey(int productId, string rulesVersion, string priceVersion) =>
$"sp-bind|{productId}|{rulesVersion}|{priceVersion}";
private async Task<ProductSnapshotBinding> ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order) private async Task<ProductSnapshotBinding> ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order)
{ {
var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime; var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime;
@ -1908,13 +1962,21 @@ public partial class OrderService
} }
else else
{ {
var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == weightRuleId.Value); await WeightRoundingDbGate.WaitAsync();
if (weightRule == null) try
{ {
throw new BusinessException("未配置计重规则"); var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == weightRuleId.Value);
} if (weightRule == null)
{
throw new BusinessException("未配置计重规则");
}
ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value); ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value);
}
finally
{
WeightRoundingDbGate.Release();
}
} }
var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos); var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos);
@ -1927,14 +1989,11 @@ public partial class OrderService
} }
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value); var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value); forecast.ChargeableWeight = await ApplyRoundingPreferSnapshotAsync(
forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
rawWeight, rawWeight,
ranges, ranges,
rulesSnapshot, rulesSnapshot,
weightRoundingId.Value, weightRoundingId.Value);
dbRanges);
} }
catch (BusinessException) catch (BusinessException)
{ {
@ -1959,31 +2018,20 @@ public partial class OrderService
return RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!); return RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
} }
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId); await WeightRoundingDbGate.WaitAsync();
if (rounding == null)
{
throw new BusinessException("未配置计重进位");
}
return await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId);
}
private static decimal ApplyRoundingWithSnapshotFallback(
decimal rawWeight,
IReadOnlyList<WeightRoundingInfo> ranges,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
int weightRoundingId,
IReadOnlyList<WeightRoundingInfo> dbFallbackRanges)
{
var fromSnapshot = rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public));
try try
{ {
return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId);
if (rounding == null)
{
throw new BusinessException("未配置计重进位");
}
return await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId);
} }
catch (InvalidOperationException) when (fromSnapshot && dbFallbackRanges.Count > 0) finally
{ {
return ParcelBillingCalculator.ApplyRounding(rawWeight, dbFallbackRanges); WeightRoundingDbGate.Release();
} }
} }
@ -2011,21 +2059,13 @@ public partial class OrderService
} }
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value); var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
var dbRanges = ranges;
if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public)))
{
dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
}
try try
{ {
forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback( forecast.ChargeableWeight = await ApplyRoundingPreferSnapshotAsync(
actualWeight, actualWeight,
ranges, ranges,
rulesSnapshot, rulesSnapshot,
weightRoundingId.Value, weightRoundingId.Value);
dbRanges);
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
@ -2033,6 +2073,41 @@ public partial class OrderService
} }
} }
/// <summary>
/// 优先用快照进位;仅失败时回读 DB(串行门闩,兼容查价并行)。
/// </summary>
private async Task<decimal> ApplyRoundingPreferSnapshotAsync(
decimal rawWeight,
IReadOnlyList<WeightRoundingInfo> ranges,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
int weightRoundingId)
{
var fromSnapshot = rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public));
try
{
return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
}
catch (InvalidOperationException) when (fromSnapshot)
{
await WeightRoundingDbGate.WaitAsync();
try
{
var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId);
if (dbRanges.Count == 0)
{
throw;
}
return ParcelBillingCalculator.ApplyRounding(rawWeight, dbRanges);
}
finally
{
WeightRoundingDbGate.Release();
}
}
}
private static void EnsureBillingProductActive(SalesProduct product) private static void EnsureBillingProductActive(SalesProduct product)
{ {
if (product.IsActive != EnableStatus.Enabled) if (product.IsActive != EnableStatus.Enabled)

View File

@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using Fake.Application; using Fake.Application;
using Oms.Application.Dtos.Marketing; using Oms.Application.Dtos.Marketing;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
@ -274,99 +275,161 @@ public partial class OrderService
return result; return result;
} }
foreach (var product in products.OrderBy(x => x.Id)) var orderedProducts = products.OrderBy(x => x.Id).ToList();
var productIds = orderedProducts.Select(x => x.Id).ToList();
// 批量拉发布记录(2 次查询替代 2N),再组装绑定;SqlSugar 同连接不可并发查库
var rulesMap = await SalesProductRepository.GetEffectivePublishRecordsAsync(
productIds, RulesOperatorType, bindTime);
var priceMap = await SalesProductRepository.GetEffectivePublishRecordsAsync(
productIds, PriceOperatorType, bindTime);
var bindingByProductId = new Dictionary<int, ProductSnapshotBinding>(orderedProducts.Count);
var bindingErrorByProductId = new Dictionary<int, string>();
foreach (var product in orderedProducts)
{
rulesMap.TryGetValue(product.Id, out var rulesRecord);
priceMap.TryGetValue(product.Id, out var priceRecord);
try
{
bindingByProductId[product.Id] = await BuildSnapshotBindingAsync(
product, bindTime, rulesRecord, priceRecord);
}
catch (BusinessException ex)
{
bindingErrorByProductId[product.Id] = ex.Message;
}
}
var workItems = new List<PriceCheckWorkItem>(
orderedProducts.Count * Math.Max(1, cargoTargets.Count) * Math.Max(1, warehouses.Count));
foreach (var product in orderedProducts)
{ {
foreach (var cargo in cargoTargets) foreach (var cargo in cargoTargets)
{ {
foreach (var warehouse in warehouses) foreach (var warehouse in warehouses)
{ {
var quoteContext = new ParcelQuoteContext workItems.Add(new PriceCheckWorkItem(product, cargo, warehouse));
{
CountryCode = baseContext.CountryCode,
Province = baseContext.Province,
City = baseContext.City,
PostalCode = baseContext.PostalCode,
CargoAttribute = cargo.CargoAttribute,
CargoTypeCode = cargo.Code,
WarehouseId = warehouse.Id,
WarehouseName = warehouse.WarehouseName,
Length = baseContext.Length,
Width = baseContext.Width,
Height = baseContext.Height,
ActualWeight = baseContext.ActualWeight,
AdditionalServices = baseContext.AdditionalServices,
DeclaredSum = baseContext.DeclaredSum,
DeclaredCurrency = baseContext.DeclaredCurrency
};
var attempt = await TryComputePriceCheckQuoteAsync(
product,
quoteContext,
profile,
priceTierKey,
bindTime,
cargo.DisplayName,
warehouse.WarehouseName);
if (attempt.Success)
{
var quote = attempt.Quote!;
var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
var itemHint = PriceCheckHintBuilder.BuildItemHint(profile, quote.UsedZoneFallback);
var groupCode = string.IsNullOrWhiteSpace(product.ProductGroup)
? UngroupedProductGroupCode
: product.ProductGroup.Trim();
var groupName = productGroupNameMap.TryGetValue(groupCode, out var mappedName)
? mappedName
: groupCode;
result.Items.Add(new OrderPriceCheckOutput
{
Id = quote.ProductId,
ProductCode = quote.ProductCode,
ShippingMethod = quote.ProductName,
CargoType = cargo.Code,
CargoTypeName = cargo.DisplayName,
WarehouseId = warehouse.Id,
Warehouse = warehouse.WarehouseName,
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
TradeTerms = string.IsNullOrWhiteSpace(product.TradeTerms) ? null : product.TradeTerms.Trim().ToUpperInvariant(),
ProductGroup = groupCode,
ProductGroupName = groupName,
ProductDescription = string.IsNullOrWhiteSpace(product.ProductDescription)
? null
: product.ProductDescription.Trim(),
VolumetricWeight = quote.VolumetricWeight,
ChargeableWeight = quote.ChargeableWeight,
CostFormula = summary.CostFormula,
Freight = summary.Freight,
OtherFees = summary.OtherFees,
OtherFeeList = summary.OtherFeeList,
TotalCost = summary.TotalCost,
MaxDeliveryDays = product.MaxTimeliness,
TotalCostNum = summary.TotalCost,
RulesVersion = quote.RulesVersion,
PriceVersion = quote.PriceVersion,
IsRemote = quote.IsRemote,
QuoteLevel = itemHint.QuoteLevel,
MissingFieldCodes = itemHint.MissingFieldCodes,
Disclaimers = itemHint.Disclaimers,
ZoneName = quote.ZoneName
});
continue;
}
if (attempt.Failure != null)
{
result.Failures.Add(attempt.Failure);
}
} }
} }
} }
var itemBag = new ConcurrentBag<OrderPriceCheckOutput>();
var failureBag = new ConcurrentBag<OrderPriceCheckFailureOutput>();
var parallelOptions = new ParallelOptions
{
// 快照已预加载后主要为 CPU(分区匹配/算费);限制并发避免打满 CPU
MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount, 2, 8)
};
await Parallel.ForEachAsync(workItems, parallelOptions, async (work, _) =>
{
var product = work.Product;
var cargo = work.Cargo;
var warehouse = work.Warehouse;
bindingByProductId.TryGetValue(product.Id, out var snapshotBinding);
bindingErrorByProductId.TryGetValue(product.Id, out var snapshotError);
var quoteContext = new ParcelQuoteContext
{
CountryCode = baseContext.CountryCode,
Province = baseContext.Province,
City = baseContext.City,
PostalCode = baseContext.PostalCode,
CargoAttribute = cargo.CargoAttribute,
CargoTypeCode = cargo.Code,
WarehouseId = warehouse.Id,
WarehouseName = warehouse.WarehouseName,
Length = baseContext.Length,
Width = baseContext.Width,
Height = baseContext.Height,
ActualWeight = baseContext.ActualWeight,
AdditionalServices = baseContext.AdditionalServices,
DeclaredSum = baseContext.DeclaredSum,
DeclaredCurrency = baseContext.DeclaredCurrency
};
var attempt = await TryComputePriceCheckQuoteAsync(
product,
quoteContext,
profile,
priceTierKey,
bindTime,
cargo.DisplayName,
warehouse.WarehouseName,
snapshotBinding,
snapshotError);
if (attempt.Success)
{
var quote = attempt.Quote!;
var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
var itemHint = PriceCheckHintBuilder.BuildItemHint(profile, quote.UsedZoneFallback);
var groupCode = string.IsNullOrWhiteSpace(product.ProductGroup)
? UngroupedProductGroupCode
: product.ProductGroup.Trim();
var groupName = productGroupNameMap.TryGetValue(groupCode, out var mappedName)
? mappedName
: groupCode;
itemBag.Add(new OrderPriceCheckOutput
{
Id = quote.ProductId,
ProductCode = quote.ProductCode,
ShippingMethod = quote.ProductName,
CargoType = cargo.Code,
CargoTypeName = cargo.DisplayName,
WarehouseId = warehouse.Id,
Warehouse = warehouse.WarehouseName,
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
TradeTerms = string.IsNullOrWhiteSpace(product.TradeTerms)
? null
: product.TradeTerms.Trim().ToUpperInvariant(),
ProductGroup = groupCode,
ProductGroupName = groupName,
ProductDescription = string.IsNullOrWhiteSpace(product.ProductDescription)
? null
: product.ProductDescription.Trim(),
VolumetricWeight = quote.VolumetricWeight,
ChargeableWeight = quote.ChargeableWeight,
CostFormula = summary.CostFormula,
Freight = summary.Freight,
OtherFees = summary.OtherFees,
OtherFeeList = summary.OtherFeeList,
TotalCost = summary.TotalCost,
MaxDeliveryDays = product.MaxTimeliness,
TotalCostNum = summary.TotalCost,
RulesVersion = quote.RulesVersion,
PriceVersion = quote.PriceVersion,
IsRemote = quote.IsRemote,
QuoteLevel = itemHint.QuoteLevel,
MissingFieldCodes = itemHint.MissingFieldCodes,
Disclaimers = itemHint.Disclaimers,
ZoneName = quote.ZoneName
});
return;
}
if (attempt.Failure != null)
{
failureBag.Add(attempt.Failure);
}
});
result.Items.AddRange(
itemBag
.OrderBy(x => x.Id)
.ThenBy(x => x.CargoType, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.WarehouseId));
result.Failures.AddRange(failureBag);
await ApplyMarketingToPriceCheckResultAsync(input, result); await ApplyMarketingToPriceCheckResultAsync(input, result);
return result; return result;
} }
private sealed record PriceCheckWorkItem(
SalesProduct Product,
PriceCheckCargoTarget Cargo,
WarehouseAddress Warehouse);
private async Task ApplyMarketingToPriceCheckResultAsync(OrderPriceCheckInput input, OrderPriceCheckResultOutput result) private async Task ApplyMarketingToPriceCheckResultAsync(OrderPriceCheckInput input, OrderPriceCheckResultOutput result)
{ {
if (result.Items.Count == 0) if (result.Items.Count == 0)
@ -574,21 +637,35 @@ public partial class OrderService
string? priceTierKey, string? priceTierKey,
DateTime bindTime, DateTime bindTime,
string cargoTypeName, string cargoTypeName,
string warehouseName) string warehouseName,
ProductSnapshotBinding? preloadedBinding = null,
string? preloadedBindingError = null)
{ {
if (product.IsActive != EnableStatus.Enabled) if (product.IsActive != EnableStatus.Enabled)
{ {
return PriceCheckFailure(product, "product", "产品未启用", cargoTypeName, warehouseName); return PriceCheckFailure(product, "product", "产品未启用", cargoTypeName, warehouseName);
} }
ProductSnapshotBinding snapshotBinding; if (!string.IsNullOrWhiteSpace(preloadedBindingError))
try
{ {
snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime); return PriceCheckFailure(product, "snapshot", preloadedBindingError, cargoTypeName, warehouseName);
} }
catch (BusinessException ex)
ProductSnapshotBinding snapshotBinding;
if (preloadedBinding != null)
{ {
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName); snapshotBinding = preloadedBinding;
}
else
{
try
{
snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
}
catch (BusinessException ex)
{
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
}
} }
if (await IsBlacklistHitAsync( if (await IsBlacklistHitAsync(

View File

@ -108,7 +108,10 @@ public partial class OrderService : ApplicationService
StringComparer.OrdinalIgnoreCase); StringComparer.OrdinalIgnoreCase);
var output = new OrderStatusCountsOutput var output = new OrderStatusCountsOutput
{ {
Total = rows.Sum(x => x.Count), // 「全部」不含已删除;已删除单独走 deleted Tab
Total = rows
.Where(x => !string.Equals(x.Status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
.Sum(x => x.Count),
Counts = counts, Counts = counts,
HandleCounts = handleCounts HandleCounts = handleCounts
}; };
@ -441,6 +444,7 @@ public partial class OrderService : ApplicationService
throw new BusinessException("仅草稿或已预报订单可删除"); throw new BusinessException("仅草稿或已预报订单可删除");
} }
// 业务删除:只改 OrderStatus=deleted,不做 IsDeleted 软删,否则「已删除」Tab 因全局软删过滤永远查不到。
order.OrderStatus = OrderStatusCodes.Deleted; order.OrderStatus = OrderStatusCodes.Deleted;
if (order.IsPaid > 0 && order.CustomerId.HasValue) if (order.IsPaid > 0 && order.CustomerId.HasValue)
{ {
@ -460,7 +464,6 @@ public partial class OrderService : ApplicationService
} }
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
await OrderRepository.DeleteAsync(order);
} }
/// <summary> /// <summary>

View File

@ -77,4 +77,13 @@ public interface ISalesProductRepository : IRepository<SalesProduct>
string operatorType, string operatorType,
DateTime bindTime, DateTime bindTime,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// 批量取各产品在 bindTime 时点生效的最新 Active 发布记录(含 Content)。
/// </summary>
Task<Dictionary<int, SalesProductPublishRecord>> GetEffectivePublishRecordsAsync(
IReadOnlyCollection<int> salesProductIds,
string operatorType,
DateTime bindTime,
CancellationToken cancellationToken = default);
} }

View File

@ -19,6 +19,9 @@ public static class OrderOperationActionCodes
public const string Outbound = "outbound"; public const string Outbound = "outbound";
public const string ChangePostShipStatus = "changePostShipStatus"; public const string ChangePostShipStatus = "changePostShipStatus";
/// <summary>轨迹中心状态同步(TrackChange D03/D09/D11)。</summary>
public const string TrackStatusSync = "trackStatusSync";
public static string GetActionName(string action) => action switch public static string GetActionName(string action) => action switch
{ {
EditBasic => "编辑订单详情", EditBasic => "编辑订单详情",
@ -37,6 +40,7 @@ public static class OrderOperationActionCodes
Inbound => "入库", Inbound => "入库",
Outbound => "出库", Outbound => "出库",
ChangePostShipStatus => "修改出库后状态", ChangePostShipStatus => "修改出库后状态",
TrackStatusSync => "轨迹状态同步",
_ => action _ => action
}; };
} }

View File

@ -52,6 +52,7 @@ internal sealed class OrderE2eTestHost : IAsyncDisposable
["Redis:Host"] = "127.0.0.1:6379", ["Redis:Host"] = "127.0.0.1:6379",
["Redis:Database"] = "0", ["Redis:Database"] = "0",
["OrderBatch:RabbitMq:Enabled"] = "false", ["OrderBatch:RabbitMq:Enabled"] = "false",
["Oms:TrackChangeSubscribe:Enabled"] = "false",
["Oms:TrackService:Enabled"] = "false", ["Oms:TrackService:Enabled"] = "false",
["Oms:TrackService:BaseUrl"] = "http://127.0.0.1:9090", ["Oms:TrackService:BaseUrl"] = "http://127.0.0.1:9090",
["Oms:TrackService:AppKey"] = "E2E_KEY", ["Oms:TrackService:AppKey"] = "E2E_KEY",

View File

@ -288,6 +288,54 @@ public class SalesProductRepository : OmsSqlSugarRepository<SalesProduct>, ISale
return records.FirstOrDefault(); return records.FirstOrDefault();
} }
public async Task<Dictionary<int, SalesProductPublishRecord>> GetEffectivePublishRecordsAsync(
IReadOnlyCollection<int> salesProductIds,
string operatorType,
DateTime bindTime,
CancellationToken cancellationToken = default)
{
var ids = salesProductIds.Where(x => x > 0).Distinct().ToArray();
if (ids.Length == 0)
{
return new Dictionary<int, SalesProductPublishRecord>();
}
var db = await GetDbContextAsync(cancellationToken);
// 先只取头字段,避免把历史版本的 Content 一并拉回
var headers = await db.Queryable<SalesProductPublishRecord>()
.Where(x => ids.Contains(x.SalesProductId)
&& x.OperatorType == operatorType
&& x.Status == EsSnapshotStatus.Active
&& x.PublishTime <= bindTime)
.Select(x => new SalesProductPublishRecord
{
Id = x.Id,
SalesProductId = x.SalesProductId,
PublishTime = x.PublishTime
})
.ToListAsync(cancellationToken);
if (headers.Count == 0)
{
return new Dictionary<int, SalesProductPublishRecord>();
}
var winningIds = headers
.GroupBy(x => x.SalesProductId)
.Select(g => g.OrderByDescending(x => x.PublishTime).ThenByDescending(x => x.Id).First().Id)
.ToArray();
var records = await db.Queryable<SalesProductPublishRecord>()
.Where(x => winningIds.Contains(x.Id))
.ToListAsync(cancellationToken);
return records
.GroupBy(x => x.SalesProductId)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(x => x.PublishTime).ThenByDescending(x => x.Id).First());
}
private static async Task InsertPriceChildrenAsync( private static async Task InsertPriceChildrenAsync(
SqlSugar.ISqlSugarClient db, SqlSugar.ISqlSugarClient db,
int priceId, int priceId,

View File

@ -48,8 +48,18 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
} }
var status = filter.Status?.Trim(); var status = filter.Status?.Trim();
if (!string.IsNullOrWhiteSpace(status) var isDeletedTab = string.Equals(status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase);
&& !string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)) if (isDeletedTab)
{
// 历史删除曾同时软删(IsDeleted=true),需清过滤才能在「已删除」看到
query = query.ClearFilter().Where(x => x.OrderStatus == OrderStatusCodes.Deleted);
}
else if (string.Equals(status, "all", StringComparison.OrdinalIgnoreCase))
{
// 列表「全部」不含已删除;status 为空时留给 status-counts 聚合(需含 deleted 桶)
query = query.Where(x => x.OrderStatus != OrderStatusCodes.Deleted);
}
else if (!string.IsNullOrWhiteSpace(status))
{ {
query = query.Where(x => x.OrderStatus == status); query = query.Where(x => x.OrderStatus == status);
} }
@ -460,15 +470,51 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
q => q.Where(x => x.AllocationPendingReason == null || x.AllocationPendingReason == string.Empty), q => q.Where(x => x.AllocationPendingReason == null || x.AllocationPendingReason == string.Empty),
q => q.Where(x => x.AllocationPendingReason != null && x.AllocationPendingReason != string.Empty)); q => q.Where(x => x.AllocationPendingReason != null && x.AllocationPendingReason != string.Empty));
} }
else if (string.Equals(field, "isPaid", StringComparison.OrdinalIgnoreCase)) else if (string.Equals(field, "isPaid", StringComparison.OrdinalIgnoreCase)
|| string.Equals(field, "isIntercept", StringComparison.OrdinalIgnoreCase)
|| string.Equals(field, "isRemote", StringComparison.OrdinalIgnoreCase)
|| string.Equals(field, "isCod", StringComparison.OrdinalIgnoreCase))
{ {
if (byte.TryParse(value, out var paid) || TryParseBoolByte(value, out paid)) query = ApplyByteFlagField(query, field, op, value, values);
}
else if (string.Equals(field, "handleStatus", StringComparison.OrdinalIgnoreCase))
{
query = ApplyStringField(query, op, value, values,
(q, v) => q.Where(x => (x.HandleStatus ?? string.Empty).Contains(v)),
(q, v) => q.Where(x => x.HandleStatus == v),
(q, v) => q.Where(x => x.HandleStatus != v),
(q, list) => q.Where(x => x.HandleStatus != null && list.Contains(x.HandleStatus)),
(q, list) => q.Where(x => x.HandleStatus == null || !list.Contains(x.HandleStatus)),
q => q.Where(x => x.HandleStatus == null || x.HandleStatus == string.Empty),
q => q.Where(x => x.HandleStatus != null && x.HandleStatus != string.Empty));
}
else if (string.Equals(field, "previousOrderStatus", StringComparison.OrdinalIgnoreCase))
{
query = ApplyStringField(query, op, value, values,
(q, v) => q.Where(x => (x.PreviousOrderStatus ?? string.Empty).Contains(v)),
(q, v) => q.Where(x => x.PreviousOrderStatus == v),
(q, v) => q.Where(x => x.PreviousOrderStatus != v),
(q, list) => q.Where(x => x.PreviousOrderStatus != null && list.Contains(x.PreviousOrderStatus)),
(q, list) => q.Where(x => x.PreviousOrderStatus == null || !list.Contains(x.PreviousOrderStatus)),
q => q.Where(x => x.PreviousOrderStatus == null || x.PreviousOrderStatus == string.Empty),
q => q.Where(x => x.PreviousOrderStatus != null && x.PreviousOrderStatus != string.Empty));
}
else if (string.Equals(field, "stockBindStatus", StringComparison.OrdinalIgnoreCase))
{
var statusValue = values.Count == 1 ? values[0] : value;
if (!string.IsNullOrWhiteSpace(statusValue)
&& (op is "eq" or "in" or "like" || string.IsNullOrWhiteSpace(op)))
{ {
query = op switch query = ApplyStockBindStatusFilter(query, statusValue);
{ }
"ne" => query.Where(x => x.IsPaid != paid), }
_ => query.Where(x => x.IsPaid == paid) else if (string.Equals(field, "fulfillProgress", StringComparison.OrdinalIgnoreCase))
}; {
var progressValue = values.Count == 1 ? values[0] : value;
if (!string.IsNullOrWhiteSpace(progressValue)
&& (op is "eq" or "in" or "like" || string.IsNullOrWhiteSpace(op)))
{
query = ApplyFulfillProgressFilter(query, progressValue);
} }
} }
else if (string.Equals(field, "payableAmount", StringComparison.OrdinalIgnoreCase)) else if (string.Equals(field, "payableAmount", StringComparison.OrdinalIgnoreCase))
@ -547,6 +593,83 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
return false; return false;
} }
private static List<byte> ResolveBoolBytes(string value, List<string> values)
{
var raw = values.Count > 0
? values
: string.IsNullOrWhiteSpace(value)
? []
: value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
var flags = new List<byte>();
foreach (var item in raw)
{
if (byte.TryParse(item, out var flag) || TryParseBoolByte(item, out flag))
{
flags.Add(flag);
}
}
return flags.Distinct().ToList();
}
/// <summary>
/// 字节布尔标志筛选(0/1),支持 eq / ne / in / notIn。
/// </summary>
private static ISugarQueryable<Order> ApplyByteFlagField(
ISugarQueryable<Order> query,
string field,
string op,
string value,
List<string> values)
{
var flags = ResolveBoolBytes(value, values);
if (flags.Count == 0)
{
return query;
}
var negate = op is "ne" or "notIn";
var multi = op is "in" or "notIn" || flags.Count > 1;
if (string.Equals(field, "isPaid", StringComparison.OrdinalIgnoreCase))
{
return negate
? query.Where(x => !flags.Contains(x.IsPaid))
: multi
? query.Where(x => flags.Contains(x.IsPaid))
: query.Where(x => x.IsPaid == flags[0]);
}
if (string.Equals(field, "isIntercept", StringComparison.OrdinalIgnoreCase))
{
return negate
? query.Where(x => !flags.Contains(x.IsIntercept))
: multi
? query.Where(x => flags.Contains(x.IsIntercept))
: query.Where(x => x.IsIntercept == flags[0]);
}
if (string.Equals(field, "isRemote", StringComparison.OrdinalIgnoreCase))
{
return negate
? query.Where(x => !flags.Contains(x.IsRemote))
: multi
? query.Where(x => flags.Contains(x.IsRemote))
: query.Where(x => x.IsRemote == flags[0]);
}
if (string.Equals(field, "isCod", StringComparison.OrdinalIgnoreCase))
{
return negate
? query.Where(x => !flags.Contains(x.IsCod))
: multi
? query.Where(x => flags.Contains(x.IsCod))
: query.Where(x => x.IsCod == flags[0]);
}
return query;
}
private static ISugarQueryable<Order> ApplyStringField( private static ISugarQueryable<Order> ApplyStringField(
ISugarQueryable<Order> query, ISugarQueryable<Order> query,
string op, string op,
@ -1352,7 +1475,12 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var db = await GetDbContextAsync(cancellationToken); var db = await GetDbContextAsync(cancellationToken);
var query = ApplyListFilter(db.Queryable<Order>(), filter); // 含历史「状态=deleted 且已软删」的单据,保证已删除 Tab 角标可见
var query = ApplyListFilter(
db.Queryable<Order>()
.ClearFilter()
.Where(x => !x.IsDeleted || x.OrderStatus == OrderStatusCodes.Deleted),
filter);
return await query return await query
.GroupBy(x => x.OrderStatus) .GroupBy(x => x.OrderStatus)

View File

@ -0,0 +1,29 @@
using FluentAssertions;
using Oms.Application.Messaging.TrackChange;
using Oms.Domain.OrdersAggregate.Consts;
namespace Oms.Tests.Messaging;
public class TrackStatusOrderStatusMapperTests
{
[Theory]
[InlineData("D03", OrderStatusCodes.Shipping)]
[InlineData("d03", OrderStatusCodes.Shipping)]
[InlineData("D09", OrderStatusCodes.DeliveryFailed)]
[InlineData("D11", OrderStatusCodes.DeliverySuccess)]
public void TryMapToOrderStatus_KnownCodes_Maps(string code, string expected)
{
TrackStatusOrderStatusMapper.TryMapToOrderStatus(code).Should().Be(expected);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("A0701")]
[InlineData("D01")]
public void TryMapToOrderStatus_Unknown_ReturnsNull(string? code)
{
TrackStatusOrderStatusMapper.TryMapToOrderStatus(code).Should().BeNull();
}
}

View File

@ -76,7 +76,8 @@ internal sealed class SimpleAdminApiTestHost : IAsyncDisposable
["FeiShuNotice:EnableFeiShuExceptionSubscribe"] = "false", ["FeiShuNotice:EnableFeiShuExceptionSubscribe"] = "false",
["Redis:Host"] = "127.0.0.1:6379", ["Redis:Host"] = "127.0.0.1:6379",
["Redis:Database"] = "0", ["Redis:Database"] = "0",
["OrderBatch:RabbitMq:Enabled"] = "false" ["OrderBatch:RabbitMq:Enabled"] = "false",
["Oms:TrackChangeSubscribe:Enabled"] = "false"
}); });
}, environmentName: "Testing"); }, environmentName: "Testing");