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