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: "必填,材积计费用");