diff --git a/db.sql b/db.sql
index 1bcf30f..d0a2ac3 100644
--- a/db.sql
+++ b/db.sql
@@ -59,6 +59,7 @@ CREATE TABLE `t_order` (
`ProductCode` VARCHAR(128) NULL COMMENT '产品编码',
`ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码',
`ConsigneeCountry` VARCHAR(8) NULL COMMENT '国家二字码',
+ `ConsigneeCountryName` VARCHAR(64) NULL COMMENT '国家中文名称',
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
`ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式',
`PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因',
diff --git a/sql/migrate_t_order_consignee_country_name.sql b/sql/migrate_t_order_consignee_country_name.sql
new file mode 100644
index 0000000..2912e48
--- /dev/null
+++ b/sql/migrate_t_order_consignee_country_name.sql
@@ -0,0 +1,9 @@
+-- t_order 增加收件国家中文名称
+ALTER TABLE `t_order`
+ ADD COLUMN `ConsigneeCountryName` VARCHAR(64) NULL COMMENT '国家中文名称' AFTER `ConsigneeCountry`;
+
+-- 历史数据:按二字码回填中文名
+UPDATE `t_order` o
+INNER JOIN `t_country` c ON c.`CountryCode` = o.`ConsigneeCountry`
+SET o.`ConsigneeCountryName` = c.`ChineseName`
+WHERE o.`ConsigneeCountryName` IS NULL OR o.`ConsigneeCountryName` = '';
diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
index f5de7ef..7a30ac9 100644
--- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
@@ -45,6 +45,16 @@ public class OrderOutput
///
public string? ChannelName { get; set; }
+ ///
+ /// 收件国家/地区二字码。
+ ///
+ public string? ConsigneeCountry { get; set; }
+
+ ///
+ /// 收件国家/地区中文名称。
+ ///
+ public string? ConsigneeCountryName { get; set; }
+
///
/// 货物属性。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderPaginatedOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPaginatedOutput.cs
index a3dd806..ca5a6fe 100644
--- a/src/Oms.Application/Dtos/Orders/OrderPaginatedOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderPaginatedOutput.cs
@@ -36,10 +36,15 @@ public class OrderPaginatedOutput
public string? ChannelCode { get; set; }
///
- /// 收件国家/地区。
+ /// 收件国家/地区二字码。
///
public string? ConsigneeCountry { get; set; }
+ ///
+ /// 收件国家/地区中文名称。
+ ///
+ public string? ConsigneeCountryName { get; set; }
+
///
/// 货物属性。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs
index eaf77b5..e4a3261 100644
--- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs
@@ -24,4 +24,14 @@ public class OrderParcelCreateOutput
/// 面单地址。
///
public string LabelUrl { get; set; } = string.Empty;
+
+ ///
+ /// 收件国家/地区二字码。
+ ///
+ public string? ConsigneeCountry { get; set; }
+
+ ///
+ /// 收件国家/地区中文名称。
+ ///
+ public string? ConsigneeCountryName { get; set; }
}
diff --git a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs
index e61ac43..a969d50 100644
--- a/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/PublishSnapshotRuleDtos.cs
@@ -22,10 +22,10 @@ public class KpiAreaSnapshotRuleDto
///
public string? PostcodeRange { get; set; }
- /// 旧快照兼容:反序列化后由 转为 PostcodeRange 并清空。
+ /// 旧快照兼容:反序列化后由 转为 PostcodeRange 并清空。
public string? LeftPostcode { get; set; }
- /// 旧快照兼容:反序列化后由 转为 PostcodeRange 并清空。
+ /// 旧快照兼容:反序列化后由 转为 PostcodeRange 并清空。
public string? RightPostcode { get; set; }
}
@@ -56,11 +56,11 @@ public class WeightRoundingSnapshotRangeDto
public string? Public { get; set; }
- /// 旧快照/明细 API 兼容(t_weight_rounding_info.StartValue);读取后由 合并至 。
+ /// 旧快照/明细 API 兼容(t_weight_rounding_info.StartValue);读取后由 合并至 。
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public decimal StartValue { get; set; }
- /// 旧快照/明细 API 兼容(t_weight_rounding_info.EndValue);读取后由 合并至 。
+ /// 旧快照/明细 API 兼容(t_weight_rounding_info.EndValue);读取后由 合并至 。
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public decimal EndValue { get; set; }
}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs
new file mode 100644
index 0000000..2f27dc1
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderClient.cs
@@ -0,0 +1,6 @@
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+public interface ISmallBagTrackOrderClient
+{
+ Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderNotifier.cs b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderNotifier.cs
new file mode 100644
index 0000000..102aea1
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/ISmallBagTrackOrderNotifier.cs
@@ -0,0 +1,9 @@
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+///
+/// 小包预报成功后异步通知轨迹服务。
+///
+public interface ISmallBagTrackOrderNotifier
+{
+ void Enqueue(SmallBagTrackUpsertRequest request);
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOptions.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOptions.cs
new file mode 100644
index 0000000..4475d18
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOptions.cs
@@ -0,0 +1,24 @@
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+///
+/// 小包轨迹订单服务配置(Consul: Oms:TrackService)。
+///
+public class SmallBagTrackOptions
+{
+ public const string SectionName = "Oms:TrackService";
+
+ ///
+ /// 是否启用异步同步。
+ ///
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// 轨迹服务根地址,如 https://track.example.com。
+ ///
+ public string BaseUrl { get; set; } = string.Empty;
+
+ ///
+ /// HTTP 超时(秒)。
+ ///
+ public int TimeoutSeconds { get; set; } = 30;
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
new file mode 100644
index 0000000..7663a52
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderClient.cs
@@ -0,0 +1,41 @@
+using System.Net.Http.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+internal sealed class SmallBagTrackOrderClient(
+ IHttpClientFactory httpClientFactory,
+ IOptions options,
+ ILogger logger) : ISmallBagTrackOrderClient
+{
+ public const string HttpClientName = nameof(SmallBagTrackOrderClient);
+
+ public async Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default)
+ {
+ var settings = options.Value;
+ if (!settings.Enabled || string.IsNullOrWhiteSpace(settings.BaseUrl))
+ {
+ logger.LogDebug("UpsertSmallBag skipped: TrackService disabled or BaseUrl empty");
+ return;
+ }
+
+ var url = $"{settings.BaseUrl.Trim().TrimEnd('/')}/OrderV2/UpsertSmallBag";
+ var client = httpClientFactory.CreateClient(HttpClientName);
+ client.Timeout = TimeSpan.FromSeconds(Math.Max(5, settings.TimeoutSeconds));
+
+ using var response = await client.PostAsJsonAsync(url, request, cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ {
+ var body = await response.Content.ReadAsStringAsync(cancellationToken);
+ logger.LogWarning(
+ "UpsertSmallBag failed for order {OrderNo}: {StatusCode} {Body}",
+ request.OrderNo,
+ (int)response.StatusCode,
+ body);
+ return;
+ }
+
+ logger.LogInformation("UpsertSmallBag succeeded for order {OrderNo}", request.OrderNo);
+ }
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs
new file mode 100644
index 0000000..6c86365
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs
@@ -0,0 +1,26 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+internal sealed class SmallBagTrackOrderNotifier(
+ IServiceScopeFactory scopeFactory,
+ ILogger logger) : ISmallBagTrackOrderNotifier
+{
+ public void Enqueue(SmallBagTrackUpsertRequest request)
+ {
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ using var scope = scopeFactory.CreateScope();
+ var client = scope.ServiceProvider.GetRequiredService();
+ await client.UpsertAsync(request);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Async UpsertSmallBag failed for order {OrderNo}", request.OrderNo);
+ }
+ });
+ }
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs
new file mode 100644
index 0000000..cc20a45
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs
@@ -0,0 +1,16 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+public static class SmallBagTrackServiceExtensions
+{
+ public static IServiceCollection AddOmsSmallBagTrack(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.Configure(configuration.GetSection(SmallBagTrackOptions.SectionName));
+ services.AddHttpClient(SmallBagTrackOrderClient.HttpClientName);
+ services.AddSingleton();
+ services.AddSingleton();
+ return services;
+ }
+}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequest.cs
new file mode 100644
index 0000000..71d8e85
--- /dev/null
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequest.cs
@@ -0,0 +1,36 @@
+namespace Oms.Application.Integrations.SmallBagTrack;
+
+///
+/// POST /OrderV2/UpsertSmallBag 请求体。
+///
+public class SmallBagTrackUpsertRequest
+{
+ public string Client { get; set; } = string.Empty;
+
+ public string OrderNo { get; set; } = string.Empty;
+
+ public string TrackNo { get; set; } = string.Empty;
+
+ public string OtherNo { get; set; } = string.Empty;
+
+ public string Channel { get; set; } = string.Empty;
+
+ public string Product { get; set; } = string.Empty;
+
+ public string ApiCode { get; set; } = string.Empty;
+
+ public SmallBagTrackConsigneeRequest Consignee { get; set; } = new();
+}
+
+public class SmallBagTrackConsigneeRequest
+{
+ public string CountryCode { get; set; } = string.Empty;
+
+ public string Province { get; set; } = string.Empty;
+
+ public string Postcode { get; set; } = string.Empty;
+
+ public string City { get; set; } = string.Empty;
+
+ public string Email { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj
index c010438..70ea1f1 100644
--- a/src/Oms.Application/Oms.Application.csproj
+++ b/src/Oms.Application/Oms.Application.csproj
@@ -5,6 +5,8 @@
enable
enable
True
+
+ $(NoWarn);CS1591
diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs
index 631b9e4..73499e3 100644
--- a/src/Oms.Application/OmsApplicationModule.cs
+++ b/src/Oms.Application/OmsApplicationModule.cs
@@ -1,6 +1,8 @@
using Fake.Modularity;
using Fake.ObjectMapping.AutoMapper;
using Fake.Security.Claims;
+using Microsoft.Extensions.DependencyInjection;
+using Oms.Application.Integrations.SmallBagTrack;
using Oms.Domain;
namespace Oms.Application;
@@ -21,5 +23,6 @@ public class OmsApplicationModule : FakeModule
{
options.ScanProfiles();
});
+ context.Services.AddOmsSmallBagTrack(context.Services.GetConfiguration());
}
}
diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
index 0b4fad0..3c63596 100644
--- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs
@@ -275,6 +275,11 @@ public partial class SalesProductService
// 规则回滚既恢复产品配置,也恢复排货服务商列表。
var product = await GetProductOrThrowAsync(input.SalesProductId);
+ if (rulesSnapshot.Product == null)
+ {
+ throw new Fake.Application.BusinessException("发布快照缺少产品配置");
+ }
+
ApplyProductInput(product, rulesSnapshot.Product);
await SalesProductRepository.UpdateAsync(product);
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index 3b6af15..a5f72df 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -9,6 +9,7 @@ using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.SystemAggregate.Entities;
namespace Oms.Application.Services.Orders;
@@ -58,6 +59,16 @@ public partial class OrderService
var productMap = products.ToDictionary(x => x.ProductCode, StringComparer.OrdinalIgnoreCase);
var channelMap = channels.ToDictionary(x => x.ChannelCode, StringComparer.OrdinalIgnoreCase);
+ var countryCodes = orders
+ .Where(x => !string.IsNullOrWhiteSpace(x.ConsigneeCountry))
+ .Select(x => x.ConsigneeCountry!.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ var countries = countryCodes.Length == 0
+ ? []
+ : await CountryRepository.GetListAsync(x => countryCodes.Contains(x.CountryCode));
+ var countryMap = countries.ToDictionary(x => x.CountryCode, StringComparer.OrdinalIgnoreCase);
+
return orders.Select(order =>
{
senderMap.TryGetValue(order.Id, out var sender);
@@ -86,6 +97,8 @@ public partial class OrderService
&& channelMap.TryGetValue(order.ChannelCode.Trim(), out var channel)
? channel.ChannelName
: null,
+ ConsigneeCountry = order.ConsigneeCountry,
+ ConsigneeCountryName = ResolveConsigneeCountryName(order, countryMap),
CargoAttribute = order.CargoAttribute,
WarehouseId = order.WarehouseId,
Warehouse = order.WarehouseId.HasValue && warehouseMap.TryGetValue(order.WarehouseId.Value, out var warehouse)
@@ -488,6 +501,25 @@ public partial class OrderService
});
}
+ private static string? ResolveConsigneeCountryName(
+ Order order,
+ IReadOnlyDictionary countryMap)
+ {
+ if (!string.IsNullOrWhiteSpace(order.ConsigneeCountryName))
+ {
+ return order.ConsigneeCountryName.Trim();
+ }
+
+ if (string.IsNullOrWhiteSpace(order.ConsigneeCountry))
+ {
+ return null;
+ }
+
+ return countryMap.TryGetValue(order.ConsigneeCountry.Trim(), out var country)
+ ? country.ChineseName
+ : null;
+ }
+
private static OrderSenderOutput? MapSender(OrderSender? sender)
{
return sender == null
diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
index 5b70c51..1474bbb 100644
--- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
@@ -615,6 +615,7 @@ public partial class OrderService
var country = await GetCountryOrThrowAsync(input.CountryCode, "收件国家不存在");
recipient.Country = country.Id;
order.ConsigneeCountry = country.CountryCode;
+ order.ConsigneeCountryName = country.ChineseName;
await OrderRepository.UpdateAsync(order);
}
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index d7285c5..1d3147b 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -1,6 +1,7 @@
using Fake.Application;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
+using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
@@ -68,7 +69,7 @@ public partial class OrderService
}
var serviceNo = await ResolveServiceNoAsync(input.ServiceNo);
- var order = BuildDraftOrderEntity(input, product, country.CountryCode, snapshotBinding, serviceNo);
+ var order = BuildDraftOrderEntity(input, product, country.CountryCode, country.ChineseName, snapshotBinding, serviceNo);
order = await OrderRepository.InsertAsync(order);
var weights = BuildInitialWeightRows(order.Id, input);
@@ -128,6 +129,8 @@ public partial class OrderService
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
+ await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver);
+
return await BuildParcelCreateOutputAsync(order);
}
catch (BusinessException ex)
@@ -1012,6 +1015,7 @@ public partial class OrderService
OrderParcelCreateInput input,
SalesProduct product,
string consigneeCountry,
+ string consigneeCountryName,
ProductSnapshotBinding snapshotBinding,
string serviceNo)
{
@@ -1024,6 +1028,7 @@ public partial class OrderService
ProductCode = input.ProductCode,
ChannelCode = input.ChannelCode ?? product.ProductCode,
ConsigneeCountry = consigneeCountry,
+ ConsigneeCountryName = consigneeCountryName,
CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General,
ExportCustomsMode = input.ExportCustomsMode,
WarehouseId = input.WarehouseId,
@@ -1401,12 +1406,54 @@ public partial class OrderService
ClientNo = order.ClientNo,
ServiceNo = order.ServiceNo,
TrackNo = order.TrackNo,
+ ConsigneeCountry = order.ConsigneeCountry,
+ ConsigneeCountryName = order.ConsigneeCountryName,
LabelUrl = order.OrderStatus == OrderStatusCodes.Predeclared
? await CreateOrderPrintFileAsync(mapped)
: string.Empty
};
}
+ private ISmallBagTrackOrderNotifier SmallBagTrackOrderNotifier =>
+ LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 小包预报成功后异步同步轨迹订单(POST /OrderV2/UpsertSmallBag)。
+ ///
+ private async Task EnqueueSmallBagTrackUpsertAsync(
+ Order order,
+ Customer? customer,
+ OrderParcelReceiverInput? receiver)
+ {
+ var apiCode = string.Empty;
+ if (!string.IsNullOrWhiteSpace(order.ChannelCode))
+ {
+ var channel = await ServiceChannelRepository.GetByChannelCodeAsync(order.ChannelCode.Trim());
+ apiCode = channel?.ForecastCode?.Trim() ?? string.Empty;
+ }
+
+ var request = new SmallBagTrackUpsertRequest
+ {
+ Client = customer?.CustomerCode?.Trim() ?? string.Empty,
+ OrderNo = order.ServiceNo,
+ TrackNo = order.TrackNo?.Trim() ?? string.Empty,
+ OtherNo = order.ClientNo?.Trim() ?? string.Empty,
+ Channel = order.ChannelCode?.Trim() ?? string.Empty,
+ Product = order.ProductCode?.Trim() ?? string.Empty,
+ ApiCode = apiCode,
+ Consignee = new SmallBagTrackConsigneeRequest
+ {
+ CountryCode = receiver?.CountryCode?.Trim() ?? order.ConsigneeCountry?.Trim() ?? string.Empty,
+ Province = receiver?.Province?.Trim() ?? string.Empty,
+ Postcode = receiver?.ZipCode?.Trim() ?? string.Empty,
+ City = receiver?.City?.Trim() ?? string.Empty,
+ Email = receiver?.Email?.Trim() ?? string.Empty
+ }
+ };
+
+ SmallBagTrackOrderNotifier.Enqueue(request);
+ }
+
///
/// 从会话解析有效客户(客户登录自动补全;管理员须传 customerId)。
///
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index b2819f4..ba469fa 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -1,4 +1,3 @@
-using System.Linq.Expressions;
using Fake.Application;
using Fake.Domain.Exceptions;
using Microsoft.AspNetCore.Hosting;
@@ -9,6 +8,7 @@ using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Repositories;
+using Oms.Domain.OrdersAggregate;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
@@ -47,15 +47,13 @@ public partial class OrderService : ApplicationService
CustomerRepository,
CurrentUser,
null);
- var predicate = BuildPaginatedPredicate(input, customerId);
+ var filter = BuildListFilter(input, customerId);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
- var orders = await OrderRepository.GetPagedListAsync(
- predicate,
+ var (orders, total) = await OrderRepository.GetFilteredPagedListAsync(
pageIndex,
pageSize,
- new Dictionary { [nameof(Order.UpdateTime)] = false, [nameof(Order.Id)] = false });
- var total = await OrderRepository.CountAsync(predicate);
+ filter);
return new OmsPageResult { Items = await MapOrderOutputsAsync(orders), Total = total };
}
@@ -230,6 +228,11 @@ public partial class OrderService : ApplicationService
public async Task DeleteAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
+ if (order.OrderStatus is not (OrderStatusCodes.Draft or OrderStatusCodes.Predeclared))
+ {
+ throw new BusinessException("仅草稿或已预报订单可删除");
+ }
+
order.OrderStatus = OrderStatusCodes.Deleted;
await OrderRepository.UpdateAsync(order);
await OrderRepository.DeleteAsync(order);
@@ -273,9 +276,7 @@ public partial class OrderService : ApplicationService
CustomerRepository,
CurrentUser,
null);
- var orders = await OrderRepository.GetListAsync(
- BuildExportPredicate(input, customerId),
- new Dictionary { [nameof(Order.CreateTime)] = false, [nameof(Order.Id)] = false });
+ var orders = await OrderRepository.GetFilteredListAsync(BuildListFilter(input, customerId));
var fileName = $"orders-{DateTime.Now:yyyyMMddHHmmss}.xlsx";
var directory = GetOrderExportDirectory();
Directory.CreateDirectory(directory);
@@ -478,170 +479,25 @@ public partial class OrderService : ApplicationService
};
}
- ///
- /// 将订单列表请求对象转换为仓储可执行的查询表达式。
- ///
- /// 入参:分页查询条件对象。
- /// 入参:会话客户 ID,空值表示不按客户隔离。
- /// 出参:订单查询表达式。
- private Expression> BuildPaginatedPredicate(OrderPaginatedInput input, int? customerId)
- {
- return BuildOrderPredicate(
- input.Status,
- input.Keyword,
- input.CreateTimeStart,
- input.CreateTimeEnd,
- customerId);
- }
-
- ///
- /// 将订单导出请求对象转换为仓储可执行的查询表达式。
- ///
- /// 入参:导出筛选条件对象。
- /// 入参:会话客户 ID,空值表示不按客户隔离。
- /// 出参:订单查询表达式。
- private Expression> BuildExportPredicate(OrderExportInput input, int? customerId)
- {
- return BuildOrderPredicate(
- input.Status,
- input.Keyword,
- input.CreateTimeStart,
- input.CreateTimeEnd,
- customerId);
- }
-
- ///
- /// 统一构造订单查询条件。
- ///
- /// 入参:订单状态,传入 all 或空值时表示不按状态过滤。
- /// 入参:关键词,可匹配运单号、客户单号、跟踪号。
- /// 入参:创建时间开始值。
- /// 入参:创建时间结束值。
- /// 入参:客户主键,空值表示不按客户隔离。
- /// 出参:可直接交给 SqlSugar 仓储执行的订单筛选表达式。
- ///
- /// 核心业务逻辑:
- /// 1. 先把状态和关键词做统一清洗;
- /// 2. 再按时间区间是否存在拆分成不同表达式;
- /// 3. 避免把 / 放进可翻译 SQL 的表达式中,
- /// 从而规避 MySQL 下时间条件被错误翻译的问题;
- /// 4. 状态筛选同样显式分支,避免 statusValue == null || order.OrderStatus == statusValue 被 SqlSugar 误译。
- ///
- private static Expression> BuildOrderPredicate(
- string? status,
- string? keyword,
- DateTime? createTimeStart,
- DateTime? createTimeEnd,
- int? customerId = null)
- {
- status = status?.Trim();
- keyword = keyword?.Trim();
- var orderStatusFilter = !string.IsNullOrWhiteSpace(status) && !string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)
- ? status
- : null;
- var hasStatusFilter = orderStatusFilter != null;
- var hasKeywordFilter = !string.IsNullOrWhiteSpace(keyword);
- var hasCustomerFilter = customerId.HasValue;
- var customerIdValue = customerId.GetValueOrDefault();
-
- // 状态与时间均显式分支,避免 SqlSugar 翻译「闭包变量 == null || 字段 == 变量」时丢失条件。
- if (createTimeStart.HasValue && createTimeEnd.HasValue)
+ private static OrderListFilter BuildListFilter(OrderPaginatedInput input, int? customerId) =>
+ new()
{
- var start = createTimeStart.Value;
- var end = createTimeEnd.Value;
- if (hasStatusFilter)
- {
- var statusFilter = orderStatusFilter!;
- return order =>
- (!hasCustomerFilter || order.CustomerId == customerIdValue) &&
- order.OrderStatus == statusFilter &&
- (!hasKeywordFilter ||
- order.ServiceNo.Contains(keyword!) ||
- (order.ClientNo ?? string.Empty).Contains(keyword!) ||
- (order.TrackNo ?? string.Empty).Contains(keyword!)) &&
- order.CreateTime >= start &&
- order.CreateTime <= end;
- }
+ Status = input.Status,
+ Keyword = input.Keyword,
+ CreateTimeStart = input.CreateTimeStart,
+ CreateTimeEnd = input.CreateTimeEnd,
+ CustomerId = customerId
+ };
- return order =>
- (!hasCustomerFilter || order.CustomerId == customerIdValue) &&
- (!hasKeywordFilter ||
- order.ServiceNo.Contains(keyword!) ||
- (order.ClientNo ?? string.Empty).Contains(keyword!) ||
- (order.TrackNo ?? string.Empty).Contains(keyword!)) &&
- order.CreateTime >= start &&
- order.CreateTime <= end;
- }
-
- if (createTimeStart.HasValue)
+ private static OrderListFilter BuildListFilter(OrderExportInput input, int? customerId) =>
+ new()
{
- var start = createTimeStart.Value;
- if (hasStatusFilter)
- {
- var statusFilter = orderStatusFilter!;
- return order =>
- (!hasCustomerFilter || order.CustomerId == customerIdValue) &&
- order.OrderStatus == statusFilter &&
- (!hasKeywordFilter ||
- order.ServiceNo.Contains(keyword!) ||
- (order.ClientNo ?? string.Empty).Contains(keyword!) ||
- (order.TrackNo ?? string.Empty).Contains(keyword!)) &&
- order.CreateTime >= start;
- }
-
- return order =>
- (!hasCustomerFilter || order.CustomerId == customerIdValue) &&
- (!hasKeywordFilter ||
- order.ServiceNo.Contains(keyword!) ||
- (order.ClientNo ?? string.Empty).Contains(keyword!) ||
- (order.TrackNo ?? string.Empty).Contains(keyword!)) &&
- order.CreateTime >= start;
- }
-
- if (createTimeEnd.HasValue)
- {
- var end = createTimeEnd.Value;
- if (hasStatusFilter)
- {
- var statusFilter = orderStatusFilter!;
- return order =>
- (!hasCustomerFilter || order.CustomerId == customerIdValue) &&
- order.OrderStatus == statusFilter &&
- (!hasKeywordFilter ||
- order.ServiceNo.Contains(keyword!) ||
- (order.ClientNo ?? string.Empty).Contains(keyword!) ||
- (order.TrackNo ?? string.Empty).Contains(keyword!)) &&
- order.CreateTime <= end;
- }
-
- return order =>
- (!hasCustomerFilter || order.CustomerId == customerIdValue) &&
- (!hasKeywordFilter ||
- order.ServiceNo.Contains(keyword!) ||
- (order.ClientNo ?? string.Empty).Contains(keyword!) ||
- (order.TrackNo ?? string.Empty).Contains(keyword!)) &&
- order.CreateTime <= end;
- }
-
- if (hasStatusFilter)
- {
- var statusFilter = orderStatusFilter!;
- return order =>
- (!hasCustomerFilter || order.CustomerId == customerIdValue) &&
- order.OrderStatus == statusFilter &&
- (!hasKeywordFilter ||
- order.ServiceNo.Contains(keyword!) ||
- (order.ClientNo ?? string.Empty).Contains(keyword!) ||
- (order.TrackNo ?? string.Empty).Contains(keyword!));
- }
-
- return order =>
- (!hasCustomerFilter || order.CustomerId == customerIdValue) &&
- (!hasKeywordFilter ||
- order.ServiceNo.Contains(keyword!) ||
- (order.ClientNo ?? string.Empty).Contains(keyword!) ||
- (order.TrackNo ?? string.Empty).Contains(keyword!));
- }
+ Status = input.Status,
+ Keyword = input.Keyword,
+ CreateTimeStart = input.CreateTimeStart,
+ CreateTimeEnd = input.CreateTimeEnd,
+ CustomerId = customerId
+ };
private async Task EnsureCustomerCanAccessOrderAsync(Order order)
{
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
index c35bb49..afee3d1 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
@@ -20,6 +20,8 @@ public class Order : OmsAuditedAggregateRoot
public string? ConsigneeCountry { get; set; }
+ public string? ConsigneeCountryName { get; set; }
+
public string? CargoAttribute { get; set; }
public string? ExportCustomsMode { get; set; }
diff --git a/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs
new file mode 100644
index 0000000..d31ccae
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/OrderListFilter.cs
@@ -0,0 +1,17 @@
+namespace Oms.Domain.OrdersAggregate;
+
+///
+/// 订单列表/导出统一筛选条件。
+///
+public class OrderListFilter
+{
+ public string? Status { get; set; }
+
+ public string? Keyword { get; set; }
+
+ public DateTime? CreateTimeStart { get; set; }
+
+ public DateTime? CreateTimeEnd { get; set; }
+
+ public int? CustomerId { get; set; }
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs
index 55f893e..8e6ce8b 100644
--- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs
+++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs
@@ -5,6 +5,16 @@ namespace Oms.Domain.OrdersAggregate.Repositories;
public interface IOrderRepository : IRepository
{
+ Task<(List Items, int Total)> GetFilteredPagedListAsync(
+ int pageIndex,
+ int pageSize,
+ OrderListFilter filter,
+ CancellationToken cancellationToken = default);
+
+ Task> GetFilteredListAsync(
+ OrderListFilter filter,
+ CancellationToken cancellationToken = default);
+
Task GetByServiceNoAsync(string serviceNo, CancellationToken cancellationToken = default);
Task HasServiceNoAsync(string serviceNo, CancellationToken cancellationToken = default);
diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs
index f8ce891..c3a988e 100644
--- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs
+++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs
@@ -10,6 +10,72 @@ namespace Oms.SqlSugarCore.Orders;
public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, IScopedDependency
{
+ public async Task<(List Items, int Total)> GetFilteredPagedListAsync(
+ int pageIndex,
+ int pageSize,
+ OrderListFilter filter,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ var query = ApplyListFilter(db.Queryable(), filter);
+ var total = await query.CountAsync(cancellationToken);
+ var items = await query
+ .OrderBy(x => x.UpdateTime, OrderByType.Desc)
+ .OrderBy(x => x.Id, OrderByType.Desc)
+ .ToPageListAsync(pageIndex, pageSize, cancellationToken);
+ return (items, total);
+ }
+
+ public async Task> GetFilteredListAsync(
+ OrderListFilter filter,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ return await ApplyListFilter(db.Queryable(), filter)
+ .OrderBy(x => x.UpdateTime, OrderByType.Desc)
+ .OrderBy(x => x.Id, OrderByType.Desc)
+ .ToListAsync(cancellationToken);
+ }
+
+ private static ISugarQueryable ApplyListFilter(ISugarQueryable query, OrderListFilter filter)
+ {
+ if (filter.CustomerId.HasValue)
+ {
+ var customerId = filter.CustomerId.Value;
+ query = query.Where(x => x.CustomerId == customerId);
+ }
+
+ var status = filter.Status?.Trim();
+ if (!string.IsNullOrWhiteSpace(status)
+ && !string.Equals(status, "all", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.Where(x => x.OrderStatus == status);
+ }
+
+ var keyword = filter.Keyword?.Trim();
+ if (!string.IsNullOrWhiteSpace(keyword))
+ {
+ query = query.Where(x =>
+ x.ServiceNo.Contains(keyword)
+ || (x.ClientNo ?? string.Empty).Contains(keyword)
+ || (x.TrackNo ?? string.Empty).Contains(keyword));
+ }
+
+ if (filter.CreateTimeStart.HasValue)
+ {
+ var start = filter.CreateTimeStart.Value;
+ query = query.Where(x => x.CreateTime >= start);
+ }
+
+ if (filter.CreateTimeEnd.HasValue)
+ {
+ var end = filter.CreateTimeEnd.Value;
+ query = query.Where(x => x.CreateTime <= end);
+ }
+
+ return query;
+ }
+
public Task GetByServiceNoAsync(string serviceNo, CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.ServiceNo == serviceNo, cancellationToken);