From 6c8f7cb7811da37577fd10d641b9af1643f934dc Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 28 Sep 2026 14:25:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=209610=E6=B1=87=E6=80=BB=E4=B8=8B?= =?UTF-8?q?=E5=8D=95=E5=BF=85=E5=A1=AB=E7=94=B5=E5=95=86=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=86=99=E5=85=A5=E9=A2=84=E6=8A=A5=EF=BC=8C=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=AE=98=E7=BD=91=E4=BA=A7=E5=93=81=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../SalesProductPortalCatalogOutput.cs | 43 +++- .../Dtos/Orders/OrderParcelCreateInput.cs | 5 + .../SmallBagTrack/PickupBigBagTrackSyncer.cs | 8 +- .../Channels/SalesProductService.Portal.cs | 192 +++++++++++++++++- .../Services/Orders/Customs9610OrderWriter.cs | 12 +- .../Orders/OrderService.ParcelBilling.cs | 20 +- .../Templates/OrderTemplateFieldRegistry.cs | 6 +- 7 files changed, 269 insertions(+), 17 deletions(-) diff --git a/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs index fff802a..840ca84 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductPortalCatalogOutput.cs @@ -6,10 +6,15 @@ namespace Oms.Application.Dtos.Channels; public class SalesProductPortalCatalogOutput { /// - /// 分组列表(已排除国内揽收,且仅含有产品的分组)。 + /// 分组列表(字典 sales_product_group,已排除国内揽收,且仅含有产品的分组)。 /// public List Groups { get; set; } = []; + /// + /// 货型筛选项(字典 cargo_attribute)。 + /// + public List Cargos { get; set; } = []; + /// /// 启用中的销售产品(已排除国内揽收)。 /// @@ -22,12 +27,28 @@ public class SalesProductPortalCatalogOutput public class SalesProductPortalGroupOutput { /// - /// 分组编码(字典 code)。 + /// 分组编码(字典 sales_product_group.code)。 /// public string Code { get; set; } = string.Empty; /// - /// 分组名称。 + /// 分组名称(字典 sales_product_group.name)。 + /// + public string Name { get; set; } = string.Empty; +} + +/// +/// 官网货型筛选项。 +/// +public class SalesProductPortalCargoOutput +{ + /// + /// 货型编码(字典 cargo_attribute.code)。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 货型名称(字典 cargo_attribute.name)。 /// public string Name { get; set; } = string.Empty; } @@ -81,4 +102,20 @@ public class SalesProductPortalItemOutput /// 产品说明。 /// public string? ProductDescription { get; set; } + + /// + /// 官网目的地区域编码:us / eu / sea / ca_mx / au / global。 + /// 优先由价格分区国家推导;国际快递跨区时含 global。 + /// + public List DestRegions { get; set; } = []; + + /// + /// 覆盖国家二字码(来自价格分区明细,去重后截断)。 + /// + public List CoverageCountries { get; set; } = []; + + /// + /// 货型标签(字典 cargo_attribute.code,如 general / electronics / sensitive)。 + /// + public List CargoTags { get; set; } = []; } diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs index 14ab496..653924a 100644 --- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs @@ -50,6 +50,11 @@ public class OrderParcelCreateInput /// public int? ExportQualificationId { get; set; } + /// + /// 电商平台(ExportCustomsMode=9610Summary 时必填,写入 t_9610_order.PlatformCode)。 + /// + public string? PlatformCode { get; set; } + /// /// 仓库主键。 /// diff --git a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs index fc63b2c..7081b08 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/PickupBigBagTrackSyncer.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Oms.Domain.ChannelsAggregate.Repositories; -using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; @@ -43,7 +42,6 @@ public interface IPickupBigBagTrackSyncer internal sealed class PickupBigBagTrackSyncer( ISmallBagTrackOrderClient trackClient, - ICustomerRepository customerRepository, IPickupOrderLineRepository pickupOrderLineRepository, ISalesProductRepository salesProductRepository, IServiceChannelRepository serviceChannelRepository, @@ -85,16 +83,14 @@ internal sealed class PickupBigBagTrackSyncer( try { - var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == pickup.CustomerId, cancellationToken); - var clientCode = customer?.CustomerCode?.Trim() ?? string.Empty; var (channel, product) = await ResolveChannelProductAsync(pickup.CarrierService, cancellationToken); var country = ResolveCountry(pickup); - // 1) UpsertBigBag + // 1) UpsertBigBag:client 取 product(销售产品编码) var upsertResult = await trackClient.UpsertBigBagAsync( new SmallBagTrackUpsertBigBagRequest { - Client = clientCode, + Client = product ?? string.Empty, BagNo = bagNo, OtherNo = pickup.CarrierWaybillNo, Channel = channel, diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs b/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs index 12c0ed9..5599706 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Portal.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Channels; +using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.Shared.Enums; using Oms.Domain.SystemAggregate.Repositories; @@ -9,8 +10,22 @@ namespace Oms.Application.Services.Channels; public partial class SalesProductService { private const string SalesProductGroupDictType = "sales_product_group"; + private const string CargoAttributeDictType = "cargo_attribute"; private const string UngroupedProductGroupCode = "_ungrouped"; private const string DomesticPickupGroupNameKeyword = "国内揽收"; + private const string ExpressProductGroupCode = "express"; + private const int PortalCoverageCountryLimit = 40; + + private static readonly HashSet PortalEuCountryCodes = new(StringComparer.OrdinalIgnoreCase) + { + "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", + "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "UK" + }; + + private static readonly HashSet PortalSeaCountryCodes = new(StringComparer.OrdinalIgnoreCase) + { + "MY", "SG", "PH", "TH", "VN", "ID" + }; private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService(); @@ -24,6 +39,7 @@ public partial class SalesProductService { var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled); var dictRows = await DictRepository.GetListAsync(x => x.DictType == SalesProductGroupDictType); + var cargoDictRows = await DictRepository.GetListAsync(x => x.DictType == CargoAttributeDictType); var domesticPickupGroupCodes = dictRows .Where(x => !string.IsNullOrWhiteSpace(x.DictName) @@ -59,6 +75,26 @@ public partial class SalesProductService .ThenBy(p => p.Id) .ToList(); + var areaIds = filtered + .Select(p => p.PriceAreaId) + .Where(id => id is > 0) + .Select(id => id!.Value) + .Distinct() + .ToList(); + + var countriesByAreaId = new Dictionary>(); + foreach (var areaId in areaIds) + { + var infos = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId); + var codes = infos + .Select(x => (x.CountryCode ?? string.Empty).Trim().ToUpperInvariant()) + .Where(code => code.Length == 2) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(code => code, StringComparer.OrdinalIgnoreCase) + .ToList(); + countriesByAreaId[areaId] = codes; + } + var usedGroupCodes = new HashSet(StringComparer.OrdinalIgnoreCase); var items = filtered.Select(p => { @@ -67,6 +103,11 @@ public partial class SalesProductService : p.ProductGroup.Trim(); usedGroupCodes.Add(groupCode); groupNameMap.TryGetValue(groupCode, out var groupName); + + var coverageCountries = ResolvePortalCoverageCountries(p, countriesByAreaId); + var destRegions = ResolvePortalDestRegions(p, groupCode, coverageCountries); + var cargoTags = ResolvePortalCargoTags(p); + return new SalesProductPortalItemOutput { Id = p.Id, @@ -77,7 +118,10 @@ public partial class SalesProductService MaxTimeliness = p.MaxTimeliness, TradeTerms = string.IsNullOrWhiteSpace(p.TradeTerms) ? null : p.TradeTerms.Trim().ToUpperInvariant(), RouteSort = p.RouteSort, - ProductDescription = p.ProductDescription + ProductDescription = p.ProductDescription, + DestRegions = destRegions, + CoverageCountries = coverageCountries.Take(PortalCoverageCountryLimit).ToList(), + CargoTags = cargoTags }; }).ToList(); @@ -121,10 +165,156 @@ public partial class SalesProductService }); } + var cargos = cargoDictRows + .Where(x => !string.IsNullOrWhiteSpace(x.DictCode)) + .GroupBy(x => x.DictCode!.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(g => g.OrderBy(x => x.DictSort).ThenBy(x => x.DictCode).First()) + .OrderBy(x => x.DictSort) + .ThenBy(x => x.DictCode) + .Select(x => new SalesProductPortalCargoOutput + { + Code = x.DictCode!.Trim(), + Name = string.IsNullOrWhiteSpace(x.DictName) ? x.DictCode!.Trim() : x.DictName.Trim() + }) + .ToList(); + return new SalesProductPortalCatalogOutput { Groups = groups, + Cargos = cargos, Items = items }; } + + private static List ResolvePortalCoverageCountries( + SalesProduct product, + IReadOnlyDictionary> countriesByAreaId) + { + if (product.PriceAreaId is not > 0) + { + return []; + } + + return countriesByAreaId.TryGetValue(product.PriceAreaId.Value, out var codes) + ? codes + : []; + } + + private static List ResolvePortalDestRegions( + SalesProduct product, + string groupCode, + IReadOnlyCollection coverageCountries) + { + var regions = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var code in coverageCountries) + { + if (string.Equals(code, "US", StringComparison.OrdinalIgnoreCase)) + { + regions.Add("us"); + continue; + } + + if (string.Equals(code, "CA", StringComparison.OrdinalIgnoreCase) + || string.Equals(code, "MX", StringComparison.OrdinalIgnoreCase)) + { + regions.Add("ca_mx"); + continue; + } + + if (string.Equals(code, "AU", StringComparison.OrdinalIgnoreCase) + || string.Equals(code, "NZ", StringComparison.OrdinalIgnoreCase)) + { + regions.Add("au"); + continue; + } + + if (PortalSeaCountryCodes.Contains(code)) + { + regions.Add("sea"); + continue; + } + + if (PortalEuCountryCodes.Contains(code)) + { + regions.Add("eu"); + } + } + + // 国际快递:价格分区跨多区,或分区为空时仍作为全球快递备选 + var isExpress = string.Equals(groupCode, ExpressProductGroupCode, StringComparison.OrdinalIgnoreCase) + || ContainsAnyIgnoreCase(product.ProductName, "DHL", "UPS", "FedEx", "FEDEX", "3PE"); + if (isExpress && (regions.Count >= 2 || coverageCountries.Count == 0 || coverageCountries.Count >= 12)) + { + regions.Add("global"); + } + + // 无价格分区时,用产品名做轻量兜底(与官网前端关键词对齐) + if (regions.Count == 0) + { + var blob = $"{product.ProductName} {product.ProductDescription}"; + if (ContainsAnyIgnoreCase(blob, "美国", "USA", "United States", "美东", "美西")) regions.Add("us"); + if (ContainsAnyIgnoreCase(blob, "东南", "越南", "马来", "新加坡", "泰国", "菲律宾", "印尼")) regions.Add("sea"); + if (ContainsAnyIgnoreCase(blob, "欧盟", "欧洲", "英国", "德国", "法国")) regions.Add("eu"); + if (ContainsAnyIgnoreCase(blob, "加拿大", "墨西哥")) regions.Add("ca_mx"); + if (ContainsAnyIgnoreCase(blob, "澳洲", "澳大利亚", "新西兰")) regions.Add("au"); + if (isExpress) regions.Add("global"); + } + + return regions + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// 将产品说明映射到字典 cargo_attribute 编码(general / electronics / sensitive)。 + /// 销售产品本身暂无货型字段,官网先按说明关键词归类,便于筛选项与字典一致。 + /// + private static List ResolvePortalCargoTags(SalesProduct product) + { + var blob = $"{product.ProductName} {product.ProductDescription}"; + var tags = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (ContainsAnyIgnoreCase(blob, "带电", "电池", "锂电", "3C", "lithium", "battery", "electronics")) + { + tags.Add("electronics"); + } + + if (ContainsAnyIgnoreCase( + blob, + "化妆品", + "cosmetic", + "敏感", + "特殊", + "疑难", + "超长", + "超重", + "超大", + "oversize", + "overweight", + "special", + "sensitive")) + { + tags.Add("sensitive"); + } + + if (ContainsAnyIgnoreCase(blob, "普货", "general cargo", "general")) + { + tags.Add("general"); + } + + return tags + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool ContainsAnyIgnoreCase(string? text, params string[] needles) + { + if (string.IsNullOrWhiteSpace(text) || needles.Length == 0) + { + return false; + } + + return needles.Any(n => text.Contains(n, StringComparison.OrdinalIgnoreCase)); + } } diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs index 22b9437..14da193 100644 --- a/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs +++ b/src/Oms.Application/Services/Orders/Customs9610OrderWriter.cs @@ -29,7 +29,10 @@ public class Customs9610OrderWriter : IScopedDependency _exportQualificationRepository = exportQualificationRepository; } - public async Task UpsertFromOrderAsync(Order order, CancellationToken cancellationToken = default) + public async Task UpsertFromOrderAsync( + Order order, + string? platformCode = null, + CancellationToken cancellationToken = default) { if (order.Id <= 0) { @@ -106,6 +109,13 @@ public class Customs9610OrderWriter : IScopedDependency ApplyQualification(header, qualification); + // 下单传入的电商平台优先;刷新时若未传则保留已有值 + var normalizedPlatform = Truncate(FirstNonEmpty(platformCode, header.PlatformCode), 16); + if (!string.IsNullOrWhiteSpace(normalizedPlatform)) + { + header.PlatformCode = normalizedPlatform; + } + header.GrossWeight = grossWeight; header.Currency = FirstNonEmpty(order.DeclaredCurrency, header.Currency, "CNY")!; header.DestCountry = FirstNonEmpty(order.ConsigneeCountry, header.DestCountry) ?? string.Empty; diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index ab6f286..993df8f 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -142,7 +142,7 @@ public partial class OrderService // 客户主动「先保存为草稿」:落库草稿后直接返回,不跑预报计费。 if (input.SaveAsDraft) { - await TryUpsertCustoms9610Async(order); + await TryUpsertCustoms9610Async(order, input.PlatformCode); return await BuildParcelCreateOutputAsync(order); } @@ -158,7 +158,7 @@ public partial class OrderService await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast); SyncWeightRows(weights, forecast); await OrderRepository.ReplaceWeightsAsync(order.Id, weights); - await TryUpsertCustoms9610Async(order); + await TryUpsertCustoms9610Async(order, input.PlatformCode); var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId); var priceTierKey = await ResolvePriceTierKeyAsync(order.CustomerId); @@ -261,12 +261,12 @@ public partial class OrderService await OrderRepository.DeleteAsync(order); } - private async Task TryUpsertCustoms9610Async(Order order) + private async Task TryUpsertCustoms9610Async(Order order, string? platformCode = null) { try { await LazyServiceProvider.GetRequiredService() - .UpsertFromOrderAsync(order); + .UpsertFromOrderAsync(order, platformCode); } catch { @@ -438,10 +438,22 @@ public partial class OrderService input.ExportQualificationId = qualification.Id; } + + if (string.IsNullOrWhiteSpace(input.PlatformCode)) + { + throw new BusinessException("9610汇总申报必须填写电商平台"); + } + + input.PlatformCode = input.PlatformCode.Trim(); + if (input.PlatformCode.Length > 16) + { + throw new BusinessException("电商平台长度不能超过 16"); + } } else { input.ExportQualificationId = null; + input.PlatformCode = null; } if (string.IsNullOrWhiteSpace(input.DeclaredCurrency)) diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs index cb8505c..b5be7bd 100644 --- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs @@ -113,8 +113,9 @@ public static class OrderTemplateFieldRegistry Add("length", "长(cm)", true, "number", example: "10", hint: "必填,用于材积计费"); Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,用于材积计费"); Add("height", "高(cm)", true, "number", example: "10", hint: "必填,用于材积计费"); - Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报;填 9610Summary 时需有默认出口资质或填备案Id"); + Add("exportCustomsMode", "出口报关", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则按简易申报;填 9610Summary 时需有默认出口资质或填备案Id,并填电商平台"); Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案"); + Add("platformCode", "电商平台", false, example: "Amazon", hint: "9610汇总申报时必填,写入海关申报电商平台"); Add("receiver.recipientsName", "收件人姓名", true, group: "receiver", example: "John Smith", hint: "必填"); Add("receiver.phone", "收件人手机", true, group: "receiver", example: "13900139000", hint: "必填"); @@ -168,8 +169,9 @@ public static class OrderTemplateFieldRegistry Add("clientNo", "客户单号", true, example: "C202604230001", hint: "您自己的订单编号,同一客户不可重复"); Add("productCode", "运输方式", true, "select", "sales_product", "basic", "PROD001", hint: "从下拉选择产品编码"); Add("cargoAttribute", "货物属性", true, "select", "cargo_attribute", "basic", "general", hint: "普货选 general"); - Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报;填 9610Summary 时需有默认出口资质或填备案Id"); + Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple", hint: "不选则简易申报;填 9610Summary 时需有默认出口资质或填备案Id,并填电商平台"); Add("exportQualificationId", "出口资质备案Id", false, "number", example: "1", hint: "9610汇总申报时填写;留空则用客户默认备案"); + Add("platformCode", "电商平台", false, example: "Amazon", hint: "9610汇总申报时必填,写入海关申报电商平台"); Add("warehouseId", "发货仓库", true, "select", "warehouse", "basic", "1", hint: "从下拉选择仓库 ID"); Add("length", "长(cm)", true, "number", example: "10", hint: "必填,材积计费用"); Add("width", "宽(cm)", true, "number", example: "10", hint: "必填,材积计费用");