feat: 9610汇总下单必填电商平台写入预报,并增强官网产品目录筛选项
All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-28 14:25:11 +08:00
parent ddb8efba15
commit 6c8f7cb781
7 changed files with 269 additions and 17 deletions

View File

@ -6,10 +6,15 @@ namespace Oms.Application.Dtos.Channels;
public class SalesProductPortalCatalogOutput
{
/// <summary>
/// 分组列表(已排除国内揽收,且仅含有产品的分组)。
/// 分组列表(字典 sales_product_group,已排除国内揽收,且仅含有产品的分组)。
/// </summary>
public List<SalesProductPortalGroupOutput> Groups { get; set; } = [];
/// <summary>
/// 货型筛选项(字典 cargo_attribute)。
/// </summary>
public List<SalesProductPortalCargoOutput> Cargos { get; set; } = [];
/// <summary>
/// 启用中的销售产品(已排除国内揽收)。
/// </summary>
@ -22,12 +27,28 @@ public class SalesProductPortalCatalogOutput
public class SalesProductPortalGroupOutput
{
/// <summary>
/// 分组编码(字典 code)。
/// 分组编码(字典 sales_product_group.code)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 分组名称。
/// 分组名称(字典 sales_product_group.name)。
/// </summary>
public string Name { get; set; } = string.Empty;
}
/// <summary>
/// 官网货型筛选项。
/// </summary>
public class SalesProductPortalCargoOutput
{
/// <summary>
/// 货型编码(字典 cargo_attribute.code)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 货型名称(字典 cargo_attribute.name)。
/// </summary>
public string Name { get; set; } = string.Empty;
}
@ -81,4 +102,20 @@ public class SalesProductPortalItemOutput
/// 产品说明。
/// </summary>
public string? ProductDescription { get; set; }
/// <summary>
/// 官网目的地区域编码:us / eu / sea / ca_mx / au / global。
/// 优先由价格分区国家推导;国际快递跨区时含 global。
/// </summary>
public List<string> DestRegions { get; set; } = [];
/// <summary>
/// 覆盖国家二字码(来自价格分区明细,去重后截断)。
/// </summary>
public List<string> CoverageCountries { get; set; } = [];
/// <summary>
/// 货型标签(字典 cargo_attribute.code,如 general / electronics / sensitive)。
/// </summary>
public List<string> CargoTags { get; set; } = [];
}

View File

@ -50,6 +50,11 @@ public class OrderParcelCreateInput
/// </summary>
public int? ExportQualificationId { get; set; }
/// <summary>
/// 电商平台(ExportCustomsMode=9610Summary 时必填,写入 t_9610_order.PlatformCode)。
/// </summary>
public string? PlatformCode { get; set; }
/// <summary>
/// 仓库主键。
/// </summary>

View File

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

View File

@ -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<string> 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<string> PortalSeaCountryCodes = new(StringComparer.OrdinalIgnoreCase)
{
"MY", "SG", "PH", "TH", "VN", "ID"
};
private IDictRepository DictRepository =>
LazyServiceProvider.GetRequiredService<IDictRepository>();
@ -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<int, List<string>>();
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<string>(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<string> ResolvePortalCoverageCountries(
SalesProduct product,
IReadOnlyDictionary<int, List<string>> countriesByAreaId)
{
if (product.PriceAreaId is not > 0)
{
return [];
}
return countriesByAreaId.TryGetValue(product.PriceAreaId.Value, out var codes)
? codes
: [];
}
private static List<string> ResolvePortalDestRegions(
SalesProduct product,
string groupCode,
IReadOnlyCollection<string> coverageCountries)
{
var regions = new HashSet<string>(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();
}
/// <summary>
/// 将产品说明映射到字典 cargo_attribute 编码(general / electronics / sensitive)。
/// 销售产品本身暂无货型字段,官网先按说明关键词归类,便于筛选项与字典一致。
/// </summary>
private static List<string> ResolvePortalCargoTags(SalesProduct product)
{
var blob = $"{product.ProductName} {product.ProductDescription}";
var tags = new HashSet<string>(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));
}
}

View File

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

View File

@ -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<Customs9610OrderWriter>()
.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))

View File

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