feat(order): 修复列表状态筛选、收件国家名与小包轨迹同步
改用仓储链式 Where 修复分页列表状态过滤失效;保存并返回 ConsigneeCountryName;预报成功后异步 UpsertSmallBag;仅草稿/已预报可删除。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
095ad66717
commit
fe30d071e6
1
db.sql
1
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 '预报失败原因',
|
||||
|
||||
9
sql/migrate_t_order_consignee_country_name.sql
Normal file
9
sql/migrate_t_order_consignee_country_name.sql
Normal 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` = '';
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
namespace Oms.Application.Integrations.SmallBagTrack;
|
||||
|
||||
public interface ISmallBagTrackOrderClient
|
||||
{
|
||||
Task UpsertAsync(SmallBagTrackUpsertRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
namespace Oms.Application.Integrations.SmallBagTrack;
|
||||
|
||||
/// <summary>
|
||||
/// 小包预报成功后异步通知轨迹服务。
|
||||
/// </summary>
|
||||
public interface ISmallBagTrackOrderNotifier
|
||||
{
|
||||
void Enqueue(SmallBagTrackUpsertRequest request);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -5,6 +5,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
||||
<!-- 已写 XML 注释的 API 仍输出到文档;未补全注释的公共成员不阻塞编译 -->
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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; }
|
||||
|
||||
17
src/Oms.Domain/OrdersAggregate/OrderListFilter.cs
Normal file
17
src/Oms.Domain/OrdersAggregate/OrderListFilter.cs
Normal 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; }
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user