feat: 销售产品门户目录、线路排序与公告/到货调整

新增门户目录接口与 route_sort 字段迁移;完善公告创建与包裹到货相关逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-04 18:39:55 +08:00
parent eba1fa4867
commit 25b4211b46
13 changed files with 358 additions and 31 deletions

2
db.sql
View File

@ -509,6 +509,7 @@ CREATE TABLE `t_sales_product` (
`MinTimeliness` INT NULL COMMENT '最小时效(天)',
`MaxTimeliness` INT NULL COMMENT '最大时效(天)',
`TradeTerms` VARCHAR(16) NULL COMMENT '贸易条款:DDP/DDU',
`RouteSort` INT NOT NULL DEFAULT 0 COMMENT '官网线路排序,越小越靠前',
`AdditionalServices` VARCHAR(512) NULL COMMENT '可选附加服务字典code JSON数组',
`CustomerWaybill` VARCHAR(32) NULL COMMENT '系统面单/客户面单',
`BlacklistAreaId` INT NULL COMMENT '黑名单区ID',
@ -530,6 +531,7 @@ CREATE TABLE `t_sales_product` (
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_sp_code` (`ProductCode`),
KEY `idx_sp_group_active` (`ProductGroup`, `IsActive`),
KEY `idx_sp_route_sort` (`RouteSort`, `IsActive`),
KEY `idx_sp_scheme` (`Scheme`),
KEY `idx_sp_weight_rule` (`WeightRuleId`),
KEY `idx_sp_weight_rounding` (`WeightRoundingId`)

View File

@ -0,0 +1,36 @@
-- t_sales_product:官网线路展示排序(幂等;列已存在则跳过)
-- 数值越小越靠前;默认 0
SET @db := DATABASE();
SET @col := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_sales_product'
AND COLUMN_NAME = 'RouteSort'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `t_sales_product` ADD COLUMN `RouteSort` INT NOT NULL DEFAULT 0 COMMENT ''官网线路排序,越小越靠前'' AFTER `TradeTerms`',
'SELECT ''skip RouteSort'' AS msg'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @idx := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_sales_product'
AND INDEX_NAME = 'idx_sp_route_sort'
);
SET @sql_idx := IF(
@idx = 0,
'ALTER TABLE `t_sales_product` ADD KEY `idx_sp_route_sort` (`RouteSort`, `IsActive`)',
'SELECT ''skip idx_sp_route_sort'' AS msg'
);
PREPARE stmt_idx FROM @sql_idx;
EXECUTE stmt_idx;
DEALLOCATE PREPARE stmt_idx;

View File

@ -40,6 +40,11 @@ public class SalesProductDetailOutput
/// </summary>
public string? TradeTerms { get; set; }
/// <summary>
/// 官网线路排序,数值越小越靠前。
/// </summary>
public int RouteSort { get; set; }
/// <summary>
/// 可选附加服务字典 code JSON 数组。
/// </summary>

View File

@ -35,6 +35,11 @@ public class SalesProductListOutput
/// </summary>
public int? MaxTimeliness { get; set; }
/// <summary>
/// 官网线路排序,数值越小越靠前。
/// </summary>
public int RouteSort { get; set; }
/// <summary>
/// 可选附加服务字典 code JSON 数组。
/// </summary>

View File

@ -0,0 +1,84 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 官网线路目录出参(公开字段)。
/// </summary>
public class SalesProductPortalCatalogOutput
{
/// <summary>
/// 分组列表(已排除国内揽收,且仅含有产品的分组)。
/// </summary>
public List<SalesProductPortalGroupOutput> Groups { get; set; } = [];
/// <summary>
/// 启用中的销售产品(已排除国内揽收)。
/// </summary>
public List<SalesProductPortalItemOutput> Items { get; set; } = [];
}
/// <summary>
/// 官网线路分组。
/// </summary>
public class SalesProductPortalGroupOutput
{
/// <summary>
/// 分组编码(字典 code)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 分组名称。
/// </summary>
public string Name { get; set; } = string.Empty;
}
/// <summary>
/// 官网线路产品卡片字段。
/// </summary>
public class SalesProductPortalItemOutput
{
/// <summary>
/// 主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 产品名称。
/// </summary>
public string ProductName { get; set; } = string.Empty;
/// <summary>
/// 产品分组编码。
/// </summary>
public string? ProductGroup { get; set; }
/// <summary>
/// 产品分组名称。
/// </summary>
public string ProductGroupName { get; set; } = string.Empty;
/// <summary>
/// 最小时效(天)。
/// </summary>
public int? MinTimeliness { get; set; }
/// <summary>
/// 最大时效(天)。
/// </summary>
public int? MaxTimeliness { get; set; }
/// <summary>
/// 贸易条款(DDP/DDU)。
/// </summary>
public string? TradeTerms { get; set; }
/// <summary>
/// 官网线路排序,数值越小越靠前。
/// </summary>
public int RouteSort { get; set; }
/// <summary>
/// 产品说明。
/// </summary>
public string? ProductDescription { get; set; }
}

View File

@ -42,6 +42,11 @@ public class SalesProductSaveInput
/// </summary>
public string? TradeTerms { get; set; }
/// <summary>
/// 官网线路排序,数值越小越靠前。
/// </summary>
public int RouteSort { get; set; }
/// <summary>
/// 可选附加服务字典 code JSON 数组。
/// </summary>

View File

@ -32,14 +32,13 @@ public class NoticeCreateInput
public byte IsPopup { get; set; }
/// <summary>
/// 接收对象类型:user/customer。
/// 接收对象类型:user/customer。公告全体广播时可空(后端按 user 占位)。
/// </summary>
[Required]
[StringLength(16)]
public string ReceiverType { get; set; } = string.Empty;
/// <summary>
/// 接收主体 ID 列表。
/// 接收主体 ID 列表。公告传空表示全体生效;站内信必填。
/// </summary>
public List<int> ReceiverIds { get; set; } = [];
}

View File

@ -579,6 +579,7 @@ public class WarehouseParcelArrivalListInput : WarehousePageInput
public class WarehouseParcelArrivalCreateInput
{
/// <summary>已废弃:到仓按仓库+快递单号定位包裹,客户归属取自包裹。</summary>
public int? CustomerId { get; set; }
[Required]

View File

@ -211,6 +211,7 @@ public partial class SalesProductService
MinTimeliness = product.MinTimeliness,
MaxTimeliness = product.MaxTimeliness,
TradeTerms = product.TradeTerms,
RouteSort = product.RouteSort,
AdditionalServices = product.AdditionalServices,
CustomerWaybill = product.CustomerWaybill,
LabelTemplateId = product.LabelTemplateId,
@ -221,7 +222,8 @@ public partial class SalesProductService
WeightRoundingId = product.WeightRoundingId,
ProductDescription = product.ProductDescription,
PreStorageScript = product.PreStorageScript,
CustomerGuideMarkdown = product.CustomerGuideMarkdown,
// 对客说明不常变动,且有独立保存入口;规则发布快照跳过,避免配置发布被其 diff 干扰
CustomerGuideMarkdown = null,
IsActive = product.IsActive,
Scheme = product.Scheme
};
@ -333,6 +335,7 @@ public partial class SalesProductService
entity.MinTimeliness = input.MinTimeliness;
entity.MaxTimeliness = input.MaxTimeliness;
entity.TradeTerms = NormalizeTradeTerms(input.TradeTerms);
entity.RouteSort = input.RouteSort < 0 ? 0 : input.RouteSort;
entity.AdditionalServices = NormalizeAdditionalServices(input.AdditionalServices);
entity.CustomerWaybill = input.CustomerWaybill;
entity.LabelTemplateId = input.LabelTemplateId;
@ -344,7 +347,7 @@ public partial class SalesProductService
entity.WeightRoundingId = input.WeightRoundingId;
entity.ProductDescription = input.ProductDescription;
entity.PreStorageScript = input.PreStorageScript;
entity.CustomerGuideMarkdown = input.CustomerGuideMarkdown;
// 对客说明仅通过 /customer-guide/save 维护,产品配置保存/发布不覆盖
entity.IsActive = input.IsActive;
entity.Scheme = string.IsNullOrWhiteSpace(input.Scheme) ? AllocationSchemeCodes.CostLowest : input.Scheme;
}
@ -444,6 +447,7 @@ public partial class SalesProductService
ProductGroup = entity.ProductGroup,
MinTimeliness = entity.MinTimeliness,
MaxTimeliness = entity.MaxTimeliness,
RouteSort = entity.RouteSort,
AdditionalServices = entity.AdditionalServices,
CustomerWaybill = entity.CustomerWaybill,
LabelTemplateId = entity.LabelTemplateId,
@ -473,6 +477,7 @@ public partial class SalesProductService
MinTimeliness = entity.MinTimeliness,
MaxTimeliness = entity.MaxTimeliness,
TradeTerms = entity.TradeTerms,
RouteSort = entity.RouteSort,
AdditionalServices = entity.AdditionalServices,
CustomerWaybill = entity.CustomerWaybill,
LabelTemplateId = entity.LabelTemplateId,

View File

@ -0,0 +1,130 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Channels;
using Oms.Domain.Shared.Enums;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.Channels;
public partial class SalesProductService
{
private const string SalesProductGroupDictType = "sales_product_group";
private const string UngroupedProductGroupCode = "_ungrouped";
private const string DomesticPickupGroupNameKeyword = "国内揽收";
private IDictRepository DictRepository =>
LazyServiceProvider.GetRequiredService<IDictRepository>();
/// <summary>
/// 官网线路目录(匿名):仅启用产品,排除「国内揽收」分组。
/// </summary>
[AllowAnonymous]
[HttpGet("/channel/sales-product/portal/catalog")]
public async Task<SalesProductPortalCatalogOutput> GetPortalCatalogAsync()
{
var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled);
var dictRows = await DictRepository.GetListAsync(x => x.DictType == SalesProductGroupDictType);
var domesticPickupGroupCodes = dictRows
.Where(x => !string.IsNullOrWhiteSpace(x.DictName)
&& x.DictName.Trim().Contains(DomesticPickupGroupNameKeyword))
.Select(x => (x.DictCode ?? string.Empty).Trim())
.Where(code => !string.IsNullOrWhiteSpace(code))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var groupNameMap = dictRows
.Where(x => !string.IsNullOrWhiteSpace(x.DictCode))
.GroupBy(x => x.DictCode!.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g =>
{
var name = g.First().DictName?.Trim();
return string.IsNullOrWhiteSpace(name) ? g.Key : name;
},
StringComparer.OrdinalIgnoreCase);
groupNameMap[UngroupedProductGroupCode] = "未分组";
var filtered = products
.Where(p =>
{
var groupCode = string.IsNullOrWhiteSpace(p.ProductGroup)
? UngroupedProductGroupCode
: p.ProductGroup.Trim();
return !domesticPickupGroupCodes.Contains(groupCode);
})
.OrderBy(p => p.RouteSort)
.ThenBy(p => p.MinTimeliness ?? int.MaxValue)
.ThenBy(p => p.ProductName, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Id)
.ToList();
var usedGroupCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var items = filtered.Select(p =>
{
var groupCode = string.IsNullOrWhiteSpace(p.ProductGroup)
? UngroupedProductGroupCode
: p.ProductGroup.Trim();
usedGroupCodes.Add(groupCode);
groupNameMap.TryGetValue(groupCode, out var groupName);
return new SalesProductPortalItemOutput
{
Id = p.Id,
ProductName = p.ProductName,
ProductGroup = groupCode == UngroupedProductGroupCode ? null : groupCode,
ProductGroupName = string.IsNullOrWhiteSpace(groupName) ? groupCode : groupName,
MinTimeliness = p.MinTimeliness,
MaxTimeliness = p.MaxTimeliness,
TradeTerms = string.IsNullOrWhiteSpace(p.TradeTerms) ? null : p.TradeTerms.Trim().ToUpperInvariant(),
RouteSort = p.RouteSort,
ProductDescription = p.ProductDescription
};
}).ToList();
var groups = new List<SalesProductPortalGroupOutput>();
var consumed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var dict in dictRows.OrderBy(x => x.DictSort).ThenBy(x => x.DictCode))
{
var code = dict.DictCode?.Trim();
if (string.IsNullOrWhiteSpace(code)
|| domesticPickupGroupCodes.Contains(code)
|| !usedGroupCodes.Contains(code)
|| !consumed.Add(code))
{
continue;
}
groups.Add(new SalesProductPortalGroupOutput
{
Code = code,
Name = string.IsNullOrWhiteSpace(dict.DictName) ? code : dict.DictName.Trim()
});
}
if (usedGroupCodes.Contains(UngroupedProductGroupCode) && consumed.Add(UngroupedProductGroupCode))
{
groups.Add(new SalesProductPortalGroupOutput
{
Code = UngroupedProductGroupCode,
Name = groupNameMap[UngroupedProductGroupCode]
});
}
foreach (var code in usedGroupCodes.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
if (!consumed.Add(code)) continue;
groupNameMap.TryGetValue(code, out var name);
groups.Add(new SalesProductPortalGroupOutput
{
Code = code,
Name = string.IsNullOrWhiteSpace(name) ? code : name
});
}
return new SalesProductPortalCatalogOutput
{
Groups = groups,
Items = items
};
}
}

View File

@ -130,14 +130,15 @@ public class NoticeService : ApplicationService
{
var noticeType = NormalizeNoticeType(input.NoticeType);
ValidateNoticeText(noticeType, input.Title, input.Content);
var receiverIds = await NormalizeAndValidateReceiverIdsAsync(input.ReceiverType, input.ReceiverIds);
var receiverType = ResolveReceiverType(noticeType, input.ReceiverType);
var receiverIds = await NormalizeAndValidateReceiverIdsAsync(noticeType, receiverType, input.ReceiverIds);
await NoticeRepository.InsertAsync(new Notice
{
NoticeType = noticeType,
Title = NormalizeTitle(input.Title),
Content = input.Content.Trim(),
IsPopup = noticeType == NoticeTypeMessage ? (byte)0 : NormalizePopupValue(input.IsPopup),
ReceiverType = NormalizeReceiverType(input.ReceiverType),
ReceiverType = receiverType,
ReceiverIds = OmsDocHelper.SerializeJson(receiverIds) ?? "[]"
});
}
@ -151,20 +152,27 @@ public class NoticeService : ApplicationService
var entity = await GetNoticeOrThrowAsync(input.Id);
var noticeType = NormalizeNoticeType(input.NoticeType);
ValidateNoticeText(noticeType, input.Title, input.Content);
var receiverIds = await NormalizeAndValidateReceiverIdsAsync(input.ReceiverType, input.ReceiverIds);
var receiverType = ResolveReceiverType(noticeType, input.ReceiverType);
var receiverIds = await NormalizeAndValidateReceiverIdsAsync(noticeType, receiverType, input.ReceiverIds);
entity.NoticeType = noticeType;
entity.Title = NormalizeTitle(input.Title);
entity.Content = input.Content.Trim();
entity.IsPopup = noticeType == NoticeTypeMessage ? (byte)0 : NormalizePopupValue(input.IsPopup);
entity.ReceiverType = NormalizeReceiverType(input.ReceiverType);
entity.ReceiverType = receiverType;
entity.ReceiverIds = OmsDocHelper.SerializeJson(receiverIds) ?? "[]";
await NoticeRepository.UpdateAsync(entity);
var existsReads = await NoticeReadRepository.GetListAsync(x => x.NoticeId == entity.Id);
var receiverIdSet = receiverIds.ToHashSet();
foreach (var read in existsReads.Where(x => x.ReceiverType != ReceiverTypeUser || !receiverIdSet.Contains(x.ReceiverId)))
// 全体广播不清理已读;定向接收时移除已不在名单内的已读记录
if (receiverIds.Count > 0)
{
await NoticeReadRepository.DeleteAsync(read);
var existsReads = await NoticeReadRepository.GetListAsync(x => x.NoticeId == entity.Id);
var receiverIdSet = receiverIds.ToHashSet();
foreach (var read in existsReads.Where(x =>
!string.Equals(x.ReceiverType, receiverType, StringComparison.OrdinalIgnoreCase) ||
!receiverIdSet.Contains(x.ReceiverId)))
{
await NoticeReadRepository.DeleteAsync(read);
}
}
}
@ -186,14 +194,17 @@ public class NoticeService : ApplicationService
{
var notice = await GetNoticeOrThrowAsync(input.Id);
var receiverIds = OmsDocHelper.DeserializeJson<List<int>>(notice.ReceiverIds) ?? [];
var currentReceiverType = NormalizeReceiverType(notice.ReceiverType);
var currentReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser)
? ReceiverTypeCustomer
: ReceiverTypeUser;
var currentReceiverId = CurrentUser.GetId<int>();
if (currentReceiverId <= 0)
{
throw new BusinessException("当前登录主体无效");
}
if (!receiverIds.Contains(currentReceiverId))
// 空接收人 = 全体广播;否则校验当前主体在定向名单内
if (receiverIds.Count > 0 && !receiverIds.Contains(currentReceiverId))
{
throw new BusinessException("当前主体不在通知接收范围内");
}
@ -355,11 +366,15 @@ public class NoticeService : ApplicationService
(string.IsNullOrWhiteSpace(keyword) ||
x.Title.Contains(keyword) ||
x.Content.Contains(keyword)) &&
x.ReceiverType == expectedReceiverType &&
(x.ReceiverIds == $"[{receiverIdToken}]" ||
x.ReceiverIds.StartsWith($"[{receiverIdToken},") ||
x.ReceiverIds.Contains($",{receiverIdToken},") ||
x.ReceiverIds.EndsWith($",{receiverIdToken}]")) &&
(
// 空接收人列表 = 全体广播(员工/客户均可见)
x.ReceiverIds == "[]" ||
(x.ReceiverType == expectedReceiverType &&
(x.ReceiverIds == $"[{receiverIdToken}]" ||
x.ReceiverIds.StartsWith($"[{receiverIdToken},") ||
x.ReceiverIds.Contains($",{receiverIdToken},") ||
x.ReceiverIds.EndsWith($",{receiverIdToken}]")))
) &&
(excluded.Count == 0 || !excluded.Contains(x.Id));
}
@ -374,19 +389,29 @@ public class NoticeService : ApplicationService
return entity;
}
private async Task<List<int>> NormalizeAndValidateReceiverIdsAsync(string receiverType, IEnumerable<int>? receiverIds)
private async Task<List<int>> NormalizeAndValidateReceiverIdsAsync(
string noticeType,
string receiverType,
IEnumerable<int>? receiverIds)
{
var normalizedReceiverType = NormalizeReceiverType(receiverType);
var selectionIds = (receiverIds ?? [])
.Where(x => x > 0)
.Distinct()
.ToList();
// 公告默认全体生效,允许空接收人;站内信必须指定接收人
if (selectionIds.Count == 0)
{
if (noticeType == NoticeTypeNotice)
{
return [];
}
throw new BusinessException("接收人不能为空");
}
var normalizedReceiverType = NormalizeReceiverType(receiverType);
if (normalizedReceiverType == ReceiverTypeCustomer)
{
var customers = await CustomerRepository.GetListAsync(x =>
@ -420,6 +445,19 @@ public class NoticeService : ApplicationService
return selectionIds;
}
/// <summary>
/// 公告全体广播时接收类型仅作占位;站内信必须明确 user/customer。
/// </summary>
private static string ResolveReceiverType(string noticeType, string? receiverType)
{
if (noticeType == NoticeTypeNotice && string.IsNullOrWhiteSpace(receiverType))
{
return ReceiverTypeUser;
}
return NormalizeReceiverType(receiverType ?? string.Empty);
}
private async Task<List<int>> QueryValidUserIdsAsync(IReadOnlyCollection<int> userIds)
{
if (userIds.Count == 0)
@ -456,6 +494,7 @@ public class NoticeService : ApplicationService
List<int> receiverSelectionIds)
{
var receiverIds = OmsDocHelper.DeserializeJson<List<int>>(entity.ReceiverIds) ?? [];
var isBroadcast = receiverIds.Count == 0;
return new NoticeOutput
{
Id = entity.Id,
@ -465,12 +504,14 @@ public class NoticeService : ApplicationService
Content = entity.Content,
IsPopup = entity.IsPopup,
ReceiverType = entity.ReceiverType,
ReceiverTypeName = entity.ReceiverType == ReceiverTypeCustomer ? "客户" : "用户",
ReceiverTypeName = isBroadcast
? "全部"
: entity.ReceiverType == ReceiverTypeCustomer ? "客户" : "用户",
ReceiverIds = receiverIds,
ReceiverSelectionIds = receiverSelectionIds,
ReadCount = readCount,
IsRead = isRead,
ReceiverCount = receiverIds.Distinct().Count(),
ReceiverCount = isBroadcast ? 0 : receiverIds.Distinct().Count(),
CreateTime = entity.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};

View File

@ -96,7 +96,10 @@ public partial class WarehouseService
[HttpPost("/warehouse/parcel-arrival/create")]
public async Task<WarehouseParcelArrivalOutput> CreateParcelArrivalAsync(WarehouseParcelArrivalCreateInput input)
{
var customerId = await ResolveMutationCustomerIdAsync(input.CustomerId);
// 到仓按仓库 + 快递单号定位包裹;客户归属取自包裹本身,管理端无需先选客户。
// 客户门户会话仍会限制只能到自己的包裹。
var sessionCustomerId = await ResolveListCustomerIdAsync(null);
var (hasCustomerFilter, customerIdValue) = CustomerFilter(sessionCustomerId);
if (input.WarehouseId <= 0)
{
throw new BusinessException("仓库ID不能为空");
@ -111,15 +114,21 @@ public partial class WarehouseService
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId)
?? throw new BusinessException("仓库不存在");
var parcel = await ParcelRepository.FirstOrDefaultAsync(x =>
x.CustomerId == customerId
&& x.WarehouseId == input.WarehouseId
&& x.TrackingNo == trackingNo);
if (parcel == null)
var parcels = await ParcelRepository.GetListAsync(x =>
x.WarehouseId == input.WarehouseId
&& x.TrackingNo == trackingNo
&& (!hasCustomerFilter || x.CustomerId == customerIdValue));
if (parcels.Count == 0)
{
throw new BusinessException("未找到对应包裹,请确认快递单号与仓库");
}
if (parcels.Count > 1)
{
throw new BusinessException("同一仓库下存在多条相同快递单号的包裹,请先在「我的包裹」核对后再到仓");
}
var parcel = parcels[0];
if (parcel.LifecycleStatus == WarehouseParcelLifecycleCodes.Stored)
{
throw new BusinessException("包裹已上架,无法再到仓");
@ -131,7 +140,7 @@ public partial class WarehouseService
var entity = new CParcelArrival
{
CustomerId = customerId,
CustomerId = parcel.CustomerId,
WarehouseId = input.WarehouseId,
ParcelId = parcel.Id,
TrackingNo = trackingNo

View File

@ -22,6 +22,11 @@ public class SalesProduct : OmsAuditedAggregateRoot
/// </summary>
public string? TradeTerms { get; set; }
/// <summary>
/// 官网线路排序,数值越小越靠前。
/// </summary>
public int RouteSort { get; set; }
/// <summary>
/// 可选附加服务字典 code JSON 数组,如 ["insurance","remoteFee"]。
/// </summary>