feat: 销售产品门户目录、线路排序与公告/到货调整
新增门户目录接口与 route_sort 字段迁移;完善公告创建与包裹到货相关逻辑。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
eba1fa4867
commit
25b4211b46
2
db.sql
2
db.sql
@ -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`)
|
||||
|
||||
36
sql/migrate_sales_product_route_sort.sql
Normal file
36
sql/migrate_sales_product_route_sort.sql
Normal 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;
|
||||
@ -40,6 +40,11 @@ public class SalesProductDetailOutput
|
||||
/// </summary>
|
||||
public string? TradeTerms { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 官网线路排序,数值越小越靠前。
|
||||
/// </summary>
|
||||
public int RouteSort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可选附加服务字典 code JSON 数组。
|
||||
/// </summary>
|
||||
|
||||
@ -35,6 +35,11 @@ public class SalesProductListOutput
|
||||
/// </summary>
|
||||
public int? MaxTimeliness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 官网线路排序,数值越小越靠前。
|
||||
/// </summary>
|
||||
public int RouteSort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可选附加服务字典 code JSON 数组。
|
||||
/// </summary>
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
@ -42,6 +42,11 @@ public class SalesProductSaveInput
|
||||
/// </summary>
|
||||
public string? TradeTerms { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 官网线路排序,数值越小越靠前。
|
||||
/// </summary>
|
||||
public int RouteSort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可选附加服务字典 code JSON 数组。
|
||||
/// </summary>
|
||||
|
||||
@ -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; } = [];
|
||||
}
|
||||
|
||||
@ -579,6 +579,7 @@ public class WarehouseParcelArrivalListInput : WarehousePageInput
|
||||
|
||||
public class WarehouseParcelArrivalCreateInput
|
||||
{
|
||||
/// <summary>已废弃:到仓按仓库+快递单号定位包裹,客户归属取自包裹。</summary>
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
[Required]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user