admin/src/Oms.Application/Services/Channels/ServiceChannelService.cs
zzh c8107cb565 feat: add additional-service billing, conditions, and fee lifecycle hardening
Support optional services and executable billing conditions on sell/buy prices, percent-of-value and percent-of-freight formulas, place-order/inbound payable on chargeable weight, outbound receivable freeze, channel remote areas, billing simulate, and insurance-aware claims.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 18:04:28 +08:00

405 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Linq.Expressions;
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
namespace Oms.Application.Services.Channels;
/// <summary>
/// 服务渠道 API 服务。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Channel")]
public partial class ServiceChannelService : ApplicationService
{
private const string PriceOperatorType = "price";
private const string RulesOperatorType = "rules";
private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
private IServiceChannelGroupRepository ServiceChannelGroupRepository => LazyServiceProvider.GetRequiredService<IServiceChannelGroupRepository>();
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
LazyServiceProvider.GetRequiredService<OmsRbacUserDisplayNameResolver>();
/// <summary>
/// 查询服务渠道列表。
/// </summary>
/// <param name="input">入参:服务渠道列表查询条件。</param>
/// <returns>服务渠道分页结果,包含列表数据和总记录数。</returns>
[HttpGet("/channel/service-channel/list")]
public async Task<OmsPageResult<ServiceChannelListOutput>> GetListAsync([FromQuery] ServiceChannelListInput input)
{
var predicate = BuildListPredicate(input);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await ServiceChannelRepository.GetPagedListAsync(
predicate,
pageIndex,
pageSize,
new Dictionary<string, bool> { [nameof(ServiceChannel.UpdateTime)] = false, [nameof(ServiceChannel.Id)] = false });
var total = await ServiceChannelRepository.CountAsync(predicate);
var groupMap = await GetGroupMapAsync();
return new OmsPageResult<ServiceChannelListOutput>
{
Items = entities.Select(entity => MapListOutput(entity, groupMap)).ToList(),
Total = total
};
}
/// <summary>
/// 查询服务渠道详情。
/// </summary>
/// <param name="id">入参:目标主键。</param>
/// <returns>服务渠道详情。</returns>
[HttpGet("/channel/service-channel/detail")]
public async Task<ServiceChannelDetailOutput> GetDetailAsync([FromQuery] int id)
{
var entity = await GetEntityOrThrowAsync(id);
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
[entity.CreateUserId, entity.UpdateUserId]);
return MapDetailOutput(entity, await GetGroupMapAsync(), auditUserNameMap);
}
/// <summary>
/// 新增服务渠道。
/// </summary>
/// <param name="input">入参:服务渠道保存参数。</param>
/// <returns>无返回体,仅完成新增服务渠道。</returns>
[HttpPost("/channel/service-channel/create")]
public async Task CreateAsync(ServiceChannelSaveInput input)
{
await ValidateChannelCodeAsync(input.ChannelCode, null);
var entity = new ServiceChannel();
ApplyInput(entity, input);
await ServiceChannelRepository.InsertAsync(entity);
}
/// <summary>
/// 更新服务渠道。
/// </summary>
/// <param name="input">入参:服务渠道保存参数。</param>
/// <returns>无返回体,仅完成更新服务渠道。</returns>
[HttpPost("/channel/service-channel/update")]
public async Task UpdateAsync(ServiceChannelSaveInput input)
{
var entity = await GetEntityOrThrowAsync(input.Id);
await ValidateChannelCodeAsync(input.ChannelCode, entity.Id);
ApplyInput(entity, input);
await ServiceChannelRepository.UpdateAsync(entity);
}
/// <summary>
/// 删除服务渠道。
/// </summary>
/// <param name="input">入参:目标主键。</param>
/// <returns>无返回体,仅完成删除服务渠道。</returns>
[HttpPost("/channel/service-channel/delete")]
public async Task DeleteAsync(IdInput input)
{
var entity = await GetEntityOrThrowAsync(input.Id);
await ServiceChannelRepository.DeleteAsync(entity);
}
/// <summary>
/// 查询服务渠道分组列表。
/// </summary>
/// <param name="input">入参:分组名称、应用 ID、API 服务商 ID 等筛选条件。</param>
/// <returns>服务渠道分组列表,供下拉与左侧树筛选使用。</returns>
[HttpGet("/channel/service-channel/group/list")]
public async Task<List<ServiceChannelGroupListOutput>> GetGroupListAsync([FromQuery] ServiceChannelGroupListInput input)
{
var predicate = BuildGroupListPredicate(input);
var entities = await ServiceChannelGroupRepository.GetListAsync(
predicate,
new Dictionary<string, bool> { [nameof(ServiceChannelGroup.Id)] = false });
return entities.Select(MapGroupListOutput).ToList();
}
/// <summary>
/// 新增服务渠道分组。
/// </summary>
/// <param name="input">入参:服务渠道分组保存参数。</param>
/// <returns>无返回体,仅完成新增服务渠道分组。</returns>
[HttpPost("/channel/service-channel/group/create")]
public async Task CreateGroupAsync(ServiceChannelGroupSaveInput input)
{
await ValidateGroupInputAsync(input, null);
await ServiceChannelGroupRepository.InsertAsync(new ServiceChannelGroup
{
Name = input.Name.Trim(),
AppId = input.AppId,
ApiServiceProviderId = input.ApiServiceProviderId
});
}
/// <summary>
/// 更新服务渠道分组。
/// </summary>
/// <param name="input">入参:服务渠道分组保存参数。</param>
/// <returns>无返回体,仅完成更新服务渠道分组。</returns>
[HttpPost("/channel/service-channel/group/update")]
public async Task UpdateGroupAsync(ServiceChannelGroupSaveInput input)
{
var group = await GetGroupOrThrowAsync(input.Id);
await ValidateGroupInputAsync(input, group.Id);
group.Name = input.Name.Trim();
group.AppId = input.AppId;
group.ApiServiceProviderId = input.ApiServiceProviderId;
await ServiceChannelGroupRepository.UpdateAsync(group);
}
/// <summary>
/// 删除服务渠道分组。
/// </summary>
/// <param name="input">入参:目标分组主键。</param>
/// <returns>无返回体,仅完成删除服务渠道分组。</returns>
[HttpPost("/channel/service-channel/group/delete")]
public async Task DeleteGroupAsync(IdInput input)
{
var group = await GetGroupOrThrowAsync(input.Id);
if (await ServiceChannelRepository.AnyAsync(x => x.GroupId == group.Id))
{
throw new BusinessException("当前分组下仍有关联渠道,无法删除");
}
await ServiceChannelGroupRepository.DeleteAsync(group);
}
/// <summary>
/// 保存服务渠道配置。
/// </summary>
/// <param name="input">入参:服务渠道保存参数。</param>
/// <returns>无返回体,仅完成保存服务渠道配置。</returns>
[ApiExplorerSettings(IgnoreApi = true)]
[HttpPost("/channel/service-channel/config/save")]
public async Task SaveConfigAsync(ServiceChannelSaveInput input)
{
var entity = await GetEntityOrThrowAsync(input.Id);
await ValidateChannelCodeAsync(input.ChannelCode, entity.Id);
ApplyInput(entity, input);
await ServiceChannelRepository.UpdateAsync(entity);
}
/// <summary>
/// 查询服务渠道价格项列表。
/// </summary>
/// <param name="serviceChannelId">入参:serviceChannelId。</param>
/// <returns>服务渠道价格项列表结果。</returns>
[HttpGet("/channel/service-channel/price/list")]
public async Task<OmsItemsResult<ServiceChannelPriceListOutput>> GetPriceListAsync([FromQuery] int serviceChannelId)
{
await GetEntityOrThrowAsync(serviceChannelId);
return new OmsItemsResult<ServiceChannelPriceListOutput>
{
Items = await GetPriceOutputsAsync(serviceChannelId)
};
}
/// <summary>
/// 新增服务渠道价格项。
/// </summary>
/// <param name="input">入参:服务渠道价格项保存参数。</param>
/// <returns>无返回体,仅完成新增服务渠道价格项。</returns>
[HttpPost("/channel/service-channel/price/create")]
public async Task CreatePriceAsync(ServiceChannelPriceSaveInput input)
{
await GetEntityOrThrowAsync(input.ServiceChannelId);
ValidateBillingPriceItemRules([input]);
await ServiceChannelRepository.InsertPriceAsync(
new ServiceChannelPrice
{
ServiceChannelId = input.ServiceChannelId,
SerialNumber = input.SerialNumber,
Status = input.Status,
PriceName = input.PriceName,
CostItem = input.CostItem,
CostUnit = input.CostUnit,
Currency = input.Currency,
ChargingCondition = input.ChargingCondition
},
BuildPriceFactors(input.Factors),
BuildPriceDetails(input.Details));
}
/// <summary>
/// 更新服务渠道价格项。
/// </summary>
/// <param name="input">入参:服务渠道价格项保存参数。</param>
/// <returns>无返回体,仅完成更新服务渠道价格项。</returns>
[HttpPost("/channel/service-channel/price/update")]
public async Task UpdatePriceAsync(ServiceChannelPriceSaveInput input)
{
var price = await GetPriceOrThrowAsync(input.Id);
if (price.ServiceChannelId != input.ServiceChannelId)
{
throw new BusinessException("买价项归属不匹配");
}
ValidateBillingPriceItemRules([input]);
price.SerialNumber = input.SerialNumber;
price.Status = input.Status;
price.PriceName = input.PriceName;
price.CostItem = input.CostItem;
price.CostUnit = input.CostUnit;
price.Currency = input.Currency;
price.ChargingCondition = input.ChargingCondition;
await ServiceChannelRepository.UpdatePriceAsync(
price,
BuildPriceFactors(input.Factors),
BuildPriceDetails(input.Details));
}
/// <summary>
/// 删除服务渠道价格项。
/// </summary>
/// <param name="input">入参:目标主键。</param>
/// <returns>无返回体,仅完成删除服务渠道价格项。</returns>
[HttpPost("/channel/service-channel/price/delete")]
public async Task DeletePriceAsync(IdInput input)
{
await GetPriceOrThrowAsync(input.Id);
await ServiceChannelRepository.DeletePriceAsync(input.Id);
}
/// <summary>
/// 切换服务渠道价格项状态。
/// </summary>
/// <param name="input">入参:目标主键和状态值。</param>
/// <returns>无返回体,仅完成切换服务渠道价格项状态。</returns>
[HttpPost("/channel/service-channel/price/toggleStatus")]
public async Task TogglePriceStatusAsync(ToggleStatusInput input)
{
await GetPriceOrThrowAsync(input.Id);
await ServiceChannelRepository.UpdatePriceStatusAsync(input.Id, input.Status);
}
/// <summary>
/// 批量保存服务渠道价格项。
/// </summary>
/// <param name="input">入参:服务渠道价格项批量保存参数。</param>
/// <returns>无返回体,仅完成批量保存服务渠道价格项。</returns>
[HttpPost("/channel/service-channel/price/save")]
public async Task SavePriceAsync(ServiceChannelPriceSaveAllInput input)
{
await GetEntityOrThrowAsync(input.ServiceChannelId);
ValidateBillingPriceItemRules(input.Prices);
var prices = new List<ServiceChannelPrice>();
var factors = new List<ServiceChannelPriceFactor>();
var details = new List<ServiceChannelPriceDetail>();
var tempId = -1;
// 使用临时负数 Id 保持买价项与子表数据在批量替换时的关联关系。
foreach (var priceInput in input.Prices.OrderBy(x => x.SerialNumber))
{
var currentTempId = tempId--;
prices.Add(new ServiceChannelPrice
{
Id = currentTempId,
ServiceChannelId = input.ServiceChannelId,
SerialNumber = priceInput.SerialNumber,
Status = priceInput.Status,
PriceName = priceInput.PriceName,
CostItem = priceInput.CostItem,
CostUnit = priceInput.CostUnit,
Currency = priceInput.Currency,
ChargingCondition = priceInput.ChargingCondition
});
factors.AddRange(BuildPriceFactors(priceInput.Factors, currentTempId));
details.AddRange(BuildPriceDetails(priceInput.Details, currentTempId));
}
await ServiceChannelRepository.ReplacePricesAsync(input.ServiceChannelId, prices, factors, details);
if (input.Publish)
{
await CreatePublishRecordAsync(input.ServiceChannelId, PriceOperatorType, "保存并发布买价");
}
}
/// <summary>
/// 查询服务渠道发布价格列表。
/// </summary>
/// <param name="input">入参:服务渠道发布列表查询条件。</param>
/// <returns>服务渠道发布价格分页结果,包含列表数据和总记录数。</returns>
[HttpGet("/channel/service-channel/publish/price/list")]
public Task<OmsPageResult<ServiceChannelPublishRecordOutput>> GetPricePublishListAsync([FromQuery] ServiceChannelPublishListInput input)
{
return GetPublishListAsync(input, PriceOperatorType);
}
/// <summary>
/// 预览即将发布的快照 JSON(不落库),与产品卖价 publish/preview 对称。
/// </summary>
[HttpGet("/channel/service-channel/publish/preview")]
public async Task<object> PreviewPublishSnapshotAsync([FromQuery] int serviceChannelId, [FromQuery] string operatorType)
{
var normalized = NormalizeOperatorType(operatorType);
object snapshot = normalized == PriceOperatorType
? await BuildPricePublishSnapshotAsync(serviceChannelId)
: await BuildRulesPublishSnapshotAsync(serviceChannelId);
return OmsDocHelper.ToSnapshotApiContent(snapshot)!;
}
/// <summary>
/// 查询服务渠道发布规则列表。
/// </summary>
/// <param name="input">入参:服务渠道发布列表查询条件。</param>
/// <returns>服务渠道发布规则分页结果,包含列表数据和总记录数。</returns>
[HttpGet("/channel/service-channel/publish/rules/list")]
public Task<OmsPageResult<ServiceChannelPublishRecordOutput>> GetRulesPublishListAsync([FromQuery] ServiceChannelPublishListInput input)
{
return GetPublishListAsync(input, RulesOperatorType);
}
/// <summary>
/// 执行服务渠道发布。
/// </summary>
/// <param name="input">入参:服务渠道发布参数。</param>
/// <returns>无返回体,仅完成服务渠道发布处理。</returns>
[HttpPost("/channel/service-channel/publish")]
public async Task PublishAsync(ServiceChannelPublishInput input)
{
await CreatePublishRecordAsync(input.ServiceChannelId, NormalizeOperatorType(input.OperatorType), input.Description);
}
/// <summary>
/// 回滚服务渠道发布版本。
/// </summary>
/// <param name="input">入参:服务渠道发布回滚参数。</param>
/// <returns>无返回体,仅完成回滚服务渠道发布版本。</returns>
[HttpPost("/channel/service-channel/publish/rollback")]
public Task RollbackAsync(ServiceChannelRollbackInput input)
{
return RollbackCoreAsync(input);
}
private static Expression<Func<ServiceChannel, bool>> BuildListPredicate(ServiceChannelListInput input)
{
var keyword = input.Keyword?.Trim();
var hasGroupFilter = input.GroupId.HasValue;
var groupIdValue = input.GroupId.GetValueOrDefault();
var hasIsActiveFilter = input.IsActive.HasValue;
var isActiveValue = input.IsActive.GetValueOrDefault();
return entity =>
(!hasGroupFilter || entity.GroupId == groupIdValue) &&
(string.IsNullOrWhiteSpace(keyword) || entity.ChannelCode.Contains(keyword) || entity.ChannelName.Contains(keyword)) &&
(!hasIsActiveFilter || entity.IsActive == isActiveValue);
}
private static Expression<Func<ServiceChannelGroup, bool>> BuildGroupListPredicate(ServiceChannelGroupListInput input)
{
var keyword = input.Keyword?.Trim();
var hasAppId = input.AppId.HasValue;
var appId = input.AppId.GetValueOrDefault();
var hasApiServiceProviderId = input.ApiServiceProviderId.HasValue;
var apiServiceProviderId = input.ApiServiceProviderId.GetValueOrDefault();
return entity =>
(string.IsNullOrWhiteSpace(keyword) || entity.Name.Contains(keyword)) &&
(!hasAppId || entity.AppId == appId) &&
(!hasApiServiceProviderId || entity.ApiServiceProviderId == apiServiceProviderId);
}
}