feat(order): 修复列表状态筛选、收件国家名与小包轨迹同步

改用仓储链式 Where 修复分页列表状态过滤失效;保存并返回 ConsigneeCountryName;预报成功后异步 UpsertSmallBag;仅草稿/已预报可删除。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-27 19:43:35 +08:00
parent 095ad66717
commit fe30d071e6
24 changed files with 410 additions and 176 deletions

1
db.sql
View File

@ -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 '预报失败原因',

View File

@ -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` = '';

View File

@ -45,6 +45,16 @@ public class OrderOutput
/// </summary>
public string? ChannelName { get; set; }
/// <summary>
/// 收件国家/地区二字码。
/// </summary>
public string? ConsigneeCountry { get; set; }
/// <summary>
/// 收件国家/地区中文名称。
/// </summary>
public string? ConsigneeCountryName { get; set; }
/// <summary>
/// 货物属性。
/// </summary>

View File

@ -36,10 +36,15 @@ public class OrderPaginatedOutput
public string? ChannelCode { get; set; }
/// <summary>
/// 收件国家/地区。
/// 收件国家/地区二字码。
/// </summary>
public string? ConsigneeCountry { get; set; }
/// <summary>
/// 收件国家/地区中文名称。
/// </summary>
public string? ConsigneeCountryName { get; set; }
/// <summary>
/// 货物属性。
/// </summary>

View File

@ -24,4 +24,14 @@ public class OrderParcelCreateOutput
/// 面单地址。
/// </summary>
public string LabelUrl { get; set; } = string.Empty;
/// <summary>
/// 收件国家/地区二字码。
/// </summary>
public string? ConsigneeCountry { get; set; }
/// <summary>
/// 收件国家/地区中文名称。
/// </summary>
public string? ConsigneeCountryName { get; set; }
}

View File

@ -22,10 +22,10 @@ public class KpiAreaSnapshotRuleDto
/// </summary>
public string? PostcodeRange { get; set; }
/// <summary>旧快照兼容:反序列化后由 <see cref="PublishSnapshotNormalizer"/> 转为 PostcodeRange 并清空。</summary>
/// <summary>旧快照兼容:反序列化后由 <see cref="Oms.Application.Shared.PublishSnapshotNormalizer"/> 转为 PostcodeRange 并清空。</summary>
public string? LeftPostcode { get; set; }
/// <summary>旧快照兼容:反序列化后由 <see cref="PublishSnapshotNormalizer"/> 转为 PostcodeRange 并清空。</summary>
/// <summary>旧快照兼容:反序列化后由 <see cref="Oms.Application.Shared.PublishSnapshotNormalizer"/> 转为 PostcodeRange 并清空。</summary>
public string? RightPostcode { get; set; }
}
@ -56,11 +56,11 @@ public class WeightRoundingSnapshotRangeDto
public string? Public { get; set; }
/// <summary>旧快照/明细 API 兼容(t_weight_rounding_info.StartValue);读取后由 <see cref="PublishSnapshotNormalizer"/> 合并至 <see cref="Start"/>。</summary>
/// <summary>旧快照/明细 API 兼容(t_weight_rounding_info.StartValue);读取后由 <see cref="Oms.Application.Shared.PublishSnapshotNormalizer"/> 合并至 <see cref="Start"/>。</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public decimal StartValue { get; set; }
/// <summary>旧快照/明细 API 兼容(t_weight_rounding_info.EndValue);读取后由 <see cref="PublishSnapshotNormalizer"/> 合并至 <see cref="End"/>。</summary>
/// <summary>旧快照/明细 API 兼容(t_weight_rounding_info.EndValue);读取后由 <see cref="Oms.Application.Shared.PublishSnapshotNormalizer"/> 合并至 <see cref="End"/>。</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public decimal EndValue { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace Oms.Application.Integrations.SmallBagTrack;
public interface ISmallBagTrackOrderClient
{
Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,9 @@
namespace Oms.Application.Integrations.SmallBagTrack;
/// <summary>
/// 小包预报成功后异步通知轨迹服务。
/// </summary>
public interface ISmallBagTrackOrderNotifier
{
void Enqueue(SmallBagTrackUpsertRequest request);
}

View File

@ -0,0 +1,24 @@
namespace Oms.Application.Integrations.SmallBagTrack;
/// <summary>
/// 小包轨迹订单服务配置(Consul: Oms:TrackService)。
/// </summary>
public class SmallBagTrackOptions
{
public const string SectionName = "Oms:TrackService";
/// <summary>
/// 是否启用异步同步。
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// 轨迹服务根地址,如 https://track.example.com。
/// </summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>
/// HTTP 超时(秒)。
/// </summary>
public int TimeoutSeconds { get; set; } = 30;
}

View File

@ -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<SmallBagTrackOptions> options,
ILogger<SmallBagTrackOrderClient> 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);
}
}

View File

@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Oms.Application.Integrations.SmallBagTrack;
internal sealed class SmallBagTrackOrderNotifier(
IServiceScopeFactory scopeFactory,
ILogger<SmallBagTrackOrderNotifier> logger) : ISmallBagTrackOrderNotifier
{
public void Enqueue(SmallBagTrackUpsertRequest request)
{
_ = Task.Run(async () =>
{
try
{
using var scope = scopeFactory.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ISmallBagTrackOrderClient>();
await client.UpsertAsync(request);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Async UpsertSmallBag failed for order {OrderNo}", request.OrderNo);
}
});
}
}

View File

@ -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<SmallBagTrackOptions>(configuration.GetSection(SmallBagTrackOptions.SectionName));
services.AddHttpClient(SmallBagTrackOrderClient.HttpClientName);
services.AddSingleton<ISmallBagTrackOrderClient, SmallBagTrackOrderClient>();
services.AddSingleton<ISmallBagTrackOrderNotifier, SmallBagTrackOrderNotifier>();
return services;
}
}

View File

@ -0,0 +1,36 @@
namespace Oms.Application.Integrations.SmallBagTrack;
/// <summary>
/// POST /OrderV2/UpsertSmallBag 请求体。
/// </summary>
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;
}

View File

@ -5,6 +5,8 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<!-- 已写 XML 注释的 API 仍输出到文档;未补全注释的公共成员不阻塞编译 -->
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>

View File

@ -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<OmsApplicationModule>();
});
context.Services.AddOmsSmallBagTrack(context.Services.GetConfiguration());
}
}

View File

@ -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);

View File

@ -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<string, Country> 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

View File

@ -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);
}
}

View File

@ -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<ISmallBagTrackOrderNotifier>();
/// <summary>
/// 小包预报成功后异步同步轨迹订单(POST /OrderV2/UpsertSmallBag)。
/// </summary>
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);
}
/// <summary>
/// 从会话解析有效客户(客户登录自动补全;管理员须传 customerId)。
/// </summary>

View File

@ -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<string, bool> { [nameof(Order.UpdateTime)] = false, [nameof(Order.Id)] = false });
var total = await OrderRepository.CountAsync(predicate);
filter);
return new OmsPageResult<OrderOutput> { 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<string, bool> { [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
};
}
/// <summary>
/// 将订单列表请求对象转换为仓储可执行的查询表达式。
/// </summary>
/// <param name="input">入参:分页查询条件对象。</param>
/// <param name="customerId">入参:会话客户 ID,空值表示不按客户隔离。</param>
/// <returns>出参:订单查询表达式。</returns>
private Expression<Func<Order, bool>> BuildPaginatedPredicate(OrderPaginatedInput input, int? customerId)
{
return BuildOrderPredicate(
input.Status,
input.Keyword,
input.CreateTimeStart,
input.CreateTimeEnd,
customerId);
}
/// <summary>
/// 将订单导出请求对象转换为仓储可执行的查询表达式。
/// </summary>
/// <param name="input">入参:导出筛选条件对象。</param>
/// <param name="customerId">入参:会话客户 ID,空值表示不按客户隔离。</param>
/// <returns>出参:订单查询表达式。</returns>
private Expression<Func<Order, bool>> BuildExportPredicate(OrderExportInput input, int? customerId)
{
return BuildOrderPredicate(
input.Status,
input.Keyword,
input.CreateTimeStart,
input.CreateTimeEnd,
customerId);
}
/// <summary>
/// 统一构造订单查询条件。
/// </summary>
/// <param name="status">入参:订单状态,传入 <c>all</c> 或空值时表示不按状态过滤。</param>
/// <param name="keyword">入参:关键词,可匹配运单号、客户单号、跟踪号。</param>
/// <param name="createTimeStart">入参:创建时间开始值。</param>
/// <param name="createTimeEnd">入参:创建时间结束值。</param>
/// <param name="customerId">入参:客户主键,空值表示不按客户隔离。</param>
/// <returns>出参:可直接交给 SqlSugar 仓储执行的订单筛选表达式。</returns>
/// <remarks>
/// 核心业务逻辑:
/// 1. 先把状态和关键词做统一清洗;
/// 2. 再按时间区间是否存在拆分成不同表达式;
/// 3. 避免把 <see cref="Nullable{T}.HasValue"/> / <see cref="Nullable{T}.Value"/> 放进可翻译 SQL 的表达式中,
/// 从而规避 MySQL 下时间条件被错误翻译的问题;
/// 4. 状态筛选同样显式分支,避免 <c>statusValue == null || order.OrderStatus == statusValue</c> 被 SqlSugar 误译。
/// </remarks>
private static Expression<Func<Order, bool>> 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)
{

View File

@ -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; }

View File

@ -0,0 +1,17 @@
namespace Oms.Domain.OrdersAggregate;
/// <summary>
/// 订单列表/导出统一筛选条件。
/// </summary>
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; }
}

View File

@ -5,6 +5,16 @@ namespace Oms.Domain.OrdersAggregate.Repositories;
public interface IOrderRepository : IRepository<Order>
{
Task<(List<Order> Items, int Total)> GetFilteredPagedListAsync(
int pageIndex,
int pageSize,
OrderListFilter filter,
CancellationToken cancellationToken = default);
Task<List<Order>> GetFilteredListAsync(
OrderListFilter filter,
CancellationToken cancellationToken = default);
Task<Order?> GetByServiceNoAsync(string serviceNo, CancellationToken cancellationToken = default);
Task<bool> HasServiceNoAsync(string serviceNo, CancellationToken cancellationToken = default);

View File

@ -10,6 +10,72 @@ namespace Oms.SqlSugarCore.Orders;
public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, IScopedDependency
{
public async Task<(List<Order> Items, int Total)> GetFilteredPagedListAsync(
int pageIndex,
int pageSize,
OrderListFilter filter,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var query = ApplyListFilter(db.Queryable<Order>(), 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<List<Order>> GetFilteredListAsync(
OrderListFilter filter,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
return await ApplyListFilter(db.Queryable<Order>(), filter)
.OrderBy(x => x.UpdateTime, OrderByType.Desc)
.OrderBy(x => x.Id, OrderByType.Desc)
.ToListAsync(cancellationToken);
}
private static ISugarQueryable<Order> ApplyListFilter(ISugarQueryable<Order> 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<Order?> GetByServiceNoAsync(string serviceNo, CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.ServiceNo == serviceNo, cancellationToken);