admin/src/Oms.Application/Services/Channels/SalesProductService.cs
zzh e5c9364434
All checks were successful
Build and Deploy / deploy (push) Successful in 1m49s
feat: 排货规则支持判断条件公式并完善失败溯源
用 MatchCondition 中文公式筛选可用渠道后再按方案选渠;失败时落完整排货过程(阶段、公式摘要、池汇总),并兼容旧 CustomerIds。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 19:43:05 +08:00

404 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 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);
}
}