Merge pull request '订阅轨迹中心轨迹状态,变更运单状态' (#15) from test into master
All checks were successful
Build and Deploy / deploy (push) Successful in 1m56s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m56s
Reviewed-on: #15
This commit is contained in:
commit
cb60028538
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
65
src/Oms.Application/Messaging/TrackChange/TrackChangeEto.cs
Normal file
65
src/Oms.Application/Messaging/TrackChange/TrackChangeEto.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
29
src/Oms.Tests/Messaging/TrackStatusOrderStatusMapperTests.cs
Normal file
29
src/Oms.Tests/Messaging/TrackStatusOrderStatusMapperTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user