All checks were successful
Build and Deploy / deploy (push) Successful in 1m49s
用 MatchCondition 中文公式筛选可用渠道后再按方案选渠;失败时落完整排货过程(阶段、公式摘要、池汇总),并兼容旧 CustomerIds。 Co-authored-by: Cursor <cursoragent@cursor.com>
404 lines
17 KiB
C#
404 lines
17 KiB
C#
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 SalesProductService : ApplicationService
|
||
{
|
||
private const string PriceOperatorType = "price";
|
||
private const string RulesOperatorType = "rules";
|
||
|
||
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
|
||
private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
|
||
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
|
||
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
|
||
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
|
||
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
|
||
LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>();
|
||
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
|
||
LazyServiceProvider.GetRequiredService<OmsRbacUserDisplayNameResolver>();
|
||
|
||
/// <summary>
|
||
/// 查询销售产品列表。
|
||
/// </summary>
|
||
/// <param name="input">入参:销售产品列表查询条件。</param>
|
||
/// <returns>销售产品分页结果,包含列表数据和总记录数。</returns>
|
||
[HttpGet("/channel/sales-product/list")]
|
||
public async Task<OmsPageResult<SalesProductListOutput>> GetListAsync([FromQuery] SalesProductListInput input)
|
||
{
|
||
var predicate = BuildListPredicate(input);
|
||
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
|
||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||
var entities = await SalesProductRepository.GetPagedListAsync(
|
||
predicate,
|
||
pageIndex,
|
||
pageSize,
|
||
new Dictionary<string, bool> { [nameof(SalesProduct.UpdateTime)] = false, [nameof(SalesProduct.Id)] = false });
|
||
var total = await SalesProductRepository.CountAsync(predicate);
|
||
return new OmsPageResult<SalesProductListOutput>
|
||
{
|
||
Items = entities.Select(MapListOutput).ToList(),
|
||
Total = total
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查询销售产品详情。
|
||
/// </summary>
|
||
/// <param name="id">入参:目标主键。</param>
|
||
/// <returns>销售产品详情。</returns>
|
||
[HttpGet("/channel/sales-product/detail")]
|
||
public async Task<SalesProductDetailOutput> GetDetailAsync([FromQuery] int id)
|
||
{
|
||
var entity = await GetProductOrThrowAsync(id);
|
||
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
|
||
[entity.CreateUserId, entity.UpdateUserId]);
|
||
return MapDetailOutput(entity, auditUserNameMap);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 新增销售产品。
|
||
/// </summary>
|
||
/// <param name="input">入参:销售产品保存参数。</param>
|
||
/// <returns>无返回体,仅完成新增销售产品。</returns>
|
||
[HttpPost("/channel/sales-product/create")]
|
||
public async Task CreateAsync(SalesProductSaveInput input)
|
||
{
|
||
await ValidateProductCodeAsync(input.ProductCode, null);
|
||
var entity = new SalesProduct();
|
||
ApplyProductInput(entity, input);
|
||
await SalesProductRepository.InsertAsync(entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 更新销售产品。
|
||
/// </summary>
|
||
/// <param name="input">入参:销售产品保存参数。</param>
|
||
/// <returns>无返回体,仅完成更新销售产品。</returns>
|
||
[HttpPost("/channel/sales-product/update")]
|
||
public async Task UpdateAsync(SalesProductSaveInput input)
|
||
{
|
||
var entity = await GetProductOrThrowAsync(input.Id);
|
||
await ValidateProductCodeAsync(input.ProductCode, entity.Id);
|
||
ApplyProductInput(entity, input);
|
||
await SalesProductRepository.UpdateAsync(entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 删除销售产品。
|
||
/// </summary>
|
||
/// <param name="input">入参:目标主键。</param>
|
||
/// <returns>无返回体,仅完成删除销售产品。</returns>
|
||
[HttpPost("/channel/sales-product/delete")]
|
||
public async Task DeleteAsync(IdInput input)
|
||
{
|
||
var entity = await GetProductOrThrowAsync(input.Id);
|
||
await SalesProductRepository.DeleteAsync(entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存销售产品配置。
|
||
/// </summary>
|
||
/// <param name="input">入参:销售产品保存参数。</param>
|
||
/// <returns>无返回体,仅完成保存销售产品配置。</returns>
|
||
[ApiExplorerSettings(IgnoreApi = true)]
|
||
[HttpPost("/channel/sales-product/config/save")]
|
||
public async Task SaveConfigAsync(SalesProductSaveInput input)
|
||
{
|
||
var entity = await GetProductOrThrowAsync(input.Id);
|
||
await ValidateProductCodeAsync(input.ProductCode, entity.Id);
|
||
ApplyProductInput(entity, input);
|
||
await SalesProductRepository.UpdateAsync(entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存销售产品对客说明。
|
||
/// </summary>
|
||
/// <param name="input">入参:对客说明 Markdown。</param>
|
||
/// <returns>无返回体,仅完成保存对客说明。</returns>
|
||
[HttpPost("/channel/sales-product/customer-guide/save")]
|
||
public async Task SaveCustomerGuideAsync(SalesProductCustomerGuideSaveInput input)
|
||
{
|
||
var entity = await GetProductOrThrowAsync(input.SalesProductId);
|
||
entity.CustomerGuideMarkdown = input.CustomerGuideMarkdown;
|
||
await SalesProductRepository.UpdateAsync(entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查询销售产品价格项列表。
|
||
/// </summary>
|
||
/// <param name="salesProductId">入参:销售产品主键。</param>
|
||
/// <returns>销售产品价格项列表结果。</returns>
|
||
[HttpGet("/channel/sales-product/price/list")]
|
||
public async Task<OmsItemsResult<SalesProductPriceListOutput>> GetPriceListAsync([FromQuery] int salesProductId)
|
||
{
|
||
await GetProductOrThrowAsync(salesProductId);
|
||
return new OmsItemsResult<SalesProductPriceListOutput>
|
||
{
|
||
Items = await GetPriceOutputsAsync(salesProductId)
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 新增销售产品价格项。
|
||
/// </summary>
|
||
/// <param name="input">入参:销售产品价格项保存参数。</param>
|
||
/// <returns>无返回体,仅完成新增销售产品价格项。</returns>
|
||
[HttpPost("/channel/sales-product/price/create")]
|
||
public async Task CreatePriceAsync(SalesProductPriceSaveInput input)
|
||
{
|
||
await GetProductOrThrowAsync(input.SalesProductId);
|
||
ValidateBillingPriceItemRules([input]);
|
||
await SalesProductRepository.InsertPriceAsync(
|
||
new SalesProductPrice
|
||
{
|
||
SalesProductId = input.SalesProductId,
|
||
SerialNumber = input.SerialNumber,
|
||
Status = input.Status,
|
||
PriceName = input.PriceName,
|
||
CostItem = input.CostItem,
|
||
CostUnit = input.CostUnit,
|
||
Currency = input.Currency,
|
||
ChargingCondition = input.ChargingCondition
|
||
},
|
||
BuildSalesProductPriceFactors(input.Factors),
|
||
BuildSalesProductPriceDetails(input.Details));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 更新销售产品价格项。
|
||
/// </summary>
|
||
/// <param name="input">入参:销售产品价格项保存参数。</param>
|
||
/// <returns>无返回体,仅完成更新销售产品价格项。</returns>
|
||
[HttpPost("/channel/sales-product/price/update")]
|
||
public async Task UpdatePriceAsync(SalesProductPriceSaveInput input)
|
||
{
|
||
var price = await GetPriceOrThrowAsync(input.Id);
|
||
if (price.SalesProductId != input.SalesProductId)
|
||
{
|
||
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 SalesProductRepository.UpdatePriceAsync(
|
||
price,
|
||
BuildSalesProductPriceFactors(input.Factors),
|
||
BuildSalesProductPriceDetails(input.Details));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 删除销售产品价格项。
|
||
/// </summary>
|
||
/// <param name="input">入参:目标主键。</param>
|
||
/// <returns>无返回体,仅完成删除销售产品价格项。</returns>
|
||
[HttpPost("/channel/sales-product/price/delete")]
|
||
public async Task DeletePriceAsync(IdInput input)
|
||
{
|
||
await GetPriceOrThrowAsync(input.Id);
|
||
await SalesProductRepository.DeletePriceAsync(input.Id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 切换销售产品价格项状态。
|
||
/// </summary>
|
||
/// <param name="input">入参:目标主键和状态值。</param>
|
||
/// <returns>无返回体,仅完成切换销售产品价格项状态。</returns>
|
||
[HttpPost("/channel/sales-product/price/toggleStatus")]
|
||
public async Task TogglePriceStatusAsync(ToggleStatusInput input)
|
||
{
|
||
await GetPriceOrThrowAsync(input.Id);
|
||
await SalesProductRepository.UpdatePriceStatusAsync(input.Id, input.Status);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 批量保存销售产品价格项。
|
||
/// </summary>
|
||
/// <param name="input">入参:销售产品价格项批量保存参数。</param>
|
||
/// <returns>无返回体,仅完成批量保存销售产品价格项。</returns>
|
||
[HttpPost("/channel/sales-product/price/save")]
|
||
public async Task SavePriceAsync(SalesProductPriceSaveAllInput input)
|
||
{
|
||
await GetProductOrThrowAsync(input.SalesProductId);
|
||
ValidateBillingPriceItemRules(input.Prices);
|
||
await PriceAreaSnapshotHelper.EnsureSingleResolvedPriceAreaAsync(
|
||
BuildPriceSnapshotFromSaveInputs(input.SalesProductId, input.Prices),
|
||
KpiAreaRepository);
|
||
var prices = new List<SalesProductPrice>();
|
||
var factors = new List<SalesProductPriceFactor>();
|
||
var details = new List<SalesProductPriceDetail>();
|
||
var tempId = -1;
|
||
|
||
// 为每棵价格子树分配临时负数 Id,仓储层再据此回填真实主键。
|
||
foreach (var priceInput in input.Prices.OrderBy(x => x.SerialNumber))
|
||
{
|
||
var currentTempId = tempId--;
|
||
prices.Add(new SalesProductPrice
|
||
{
|
||
Id = currentTempId,
|
||
SalesProductId = input.SalesProductId,
|
||
SerialNumber = priceInput.SerialNumber,
|
||
Status = priceInput.Status,
|
||
PriceName = priceInput.PriceName,
|
||
CostItem = priceInput.CostItem,
|
||
CostUnit = priceInput.CostUnit,
|
||
Currency = priceInput.Currency,
|
||
ChargingCondition = priceInput.ChargingCondition
|
||
});
|
||
factors.AddRange(BuildSalesProductPriceFactors(priceInput.Factors, currentTempId));
|
||
details.AddRange(BuildSalesProductPriceDetails(priceInput.Details, currentTempId));
|
||
}
|
||
|
||
await SalesProductRepository.ReplacePricesAsync(input.SalesProductId, prices, factors, details);
|
||
if (input.Publish)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(input.AiAnalysisReport))
|
||
{
|
||
throw new Fake.Application.BusinessException("价格发布前须完成 AI 风险分析并保存报告");
|
||
}
|
||
|
||
await CreatePublishRecordAsync(
|
||
input.SalesProductId,
|
||
PriceOperatorType,
|
||
"保存并发布价格",
|
||
input.AiAnalysisReport);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查询销售产品分配配置。
|
||
/// </summary>
|
||
/// <param name="salesProductId">入参:销售产品主键。</param>
|
||
/// <returns>销售产品分配配置。</returns>
|
||
[HttpGet("/channel/sales-product/allocation/get")]
|
||
public Task<SalesProductAllocationOutput> GetAllocationAsync([FromQuery] int salesProductId)
|
||
{
|
||
return GetAllocationCoreAsync(salesProductId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存销售产品分配配置。
|
||
/// </summary>
|
||
/// <param name="input">入参:销售产品分配配置保存参数。</param>
|
||
/// <returns>无返回体,仅完成保存销售产品分配配置。</returns>
|
||
[HttpPost("/channel/sales-product/allocation/save")]
|
||
public async Task SaveAllocationAsync(SalesProductAllocationSaveInput input)
|
||
{
|
||
var product = await GetProductOrThrowAsync(input.SalesProductId);
|
||
product.Scheme = input.Scheme;
|
||
await SalesProductRepository.UpdateAsync(product);
|
||
|
||
var providers = input.Providers.Select(provider => new SalesProductAllocationProvider
|
||
{
|
||
SalesProductId = input.SalesProductId,
|
||
SerialNumber = provider.SerialNumber,
|
||
ProviderName = provider.ProviderName,
|
||
ServiceChannelId = provider.ServiceChannelId,
|
||
Weight = provider.Weight,
|
||
CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds),
|
||
MatchCondition = provider.MatchCondition
|
||
}).ToList();
|
||
await SalesProductRepository.ReplaceAllocationProvidersAsync(input.SalesProductId, providers);
|
||
|
||
if (input.Publish)
|
||
{
|
||
await CreatePublishRecordAsync(input.SalesProductId, RulesOperatorType, "保存并发布规则");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查询销售产品发布价格列表。
|
||
/// </summary>
|
||
/// <param name="input">入参:销售产品发布列表查询条件。</param>
|
||
/// <returns>销售产品发布价格分页结果,包含列表数据和总记录数。</returns>
|
||
[HttpGet("/channel/sales-product/publish/price/list")]
|
||
public Task<OmsPageResult<SalesProductPublishRecordOutput>> GetPricePublishListAsync([FromQuery] SalesProductPublishListInput input)
|
||
{
|
||
return GetPublishListAsync(input, PriceOperatorType);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查询销售产品发布规则列表。
|
||
/// </summary>
|
||
/// <param name="input">入参:销售产品发布列表查询条件。</param>
|
||
/// <returns>销售产品发布规则分页结果,包含列表数据和总记录数。</returns>
|
||
[HttpGet("/channel/sales-product/publish/rules/list")]
|
||
public Task<OmsPageResult<SalesProductPublishRecordOutput>> GetRulesPublishListAsync([FromQuery] SalesProductPublishListInput input)
|
||
{
|
||
return GetPublishListAsync(input, RulesOperatorType);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 预览即将发布的快照 JSON(不落库),用于核对黑名单/计重等是否已固化进 Content。
|
||
/// </summary>
|
||
[HttpGet("/channel/sales-product/publish/preview")]
|
||
public async Task<object> PreviewPublishSnapshotAsync([FromQuery] int salesProductId, [FromQuery] string operatorType)
|
||
{
|
||
var normalized = NormalizeOperatorType(operatorType);
|
||
object snapshot = normalized == PriceOperatorType
|
||
? await BuildPricePublishSnapshotAsync(salesProductId)
|
||
: await BuildRulesPublishSnapshotAsync(salesProductId);
|
||
return OmsDocHelper.ToSnapshotApiContent(snapshot)!;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行销售产品发布。
|
||
/// </summary>
|
||
/// <param name="input">入参:销售产品发布参数。</param>
|
||
/// <returns>无返回体,仅完成销售产品发布处理。</returns>
|
||
[HttpPost("/channel/sales-product/publish")]
|
||
public async Task PublishAsync(SalesProductPublishInput input)
|
||
{
|
||
var operatorType = NormalizeOperatorType(input.OperatorType);
|
||
if (operatorType == PriceOperatorType && string.IsNullOrWhiteSpace(input.AiAnalysisReport))
|
||
{
|
||
throw new Fake.Application.BusinessException("价格发布前须完成 AI 风险分析并保存报告");
|
||
}
|
||
|
||
await CreatePublishRecordAsync(
|
||
input.SalesProductId,
|
||
operatorType,
|
||
input.Description,
|
||
input.AiAnalysisReport);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 回滚销售产品发布版本。
|
||
/// </summary>
|
||
/// <param name="input">入参:销售产品发布回滚参数。</param>
|
||
/// <returns>无返回体,仅完成回滚销售产品发布版本。</returns>
|
||
[HttpPost("/channel/sales-product/publish/rollback")]
|
||
public Task RollbackAsync(SalesProductRollbackInput input)
|
||
{
|
||
return RollbackCoreAsync(input);
|
||
}
|
||
|
||
private static Expression<Func<SalesProduct, bool>> BuildListPredicate(SalesProductListInput input)
|
||
{
|
||
var productGroup = input.ProductGroup?.Trim();
|
||
var keyword = input.Keyword?.Trim();
|
||
var hasIsActiveFilter = input.IsActive.HasValue;
|
||
var isActiveValue = input.IsActive.GetValueOrDefault();
|
||
return entity =>
|
||
(string.IsNullOrWhiteSpace(productGroup) || entity.ProductGroup == productGroup) &&
|
||
(string.IsNullOrWhiteSpace(keyword) || entity.ProductCode.Contains(keyword) || entity.ProductName.Contains(keyword)) &&
|
||
(!hasIsActiveFilter || entity.IsActive == isActiveValue);
|
||
}
|
||
}
|