using System.ComponentModel.DataAnnotations; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using MarketingDAL.CustomCode.Product; using MarketingDAL.DTO.Product; using Microsoft.Extensions.Logging; using static GB5Shared.GB5Constant.Constant; namespace MarketingBLL.Product; public class ProductBLL : IProductBLL { private readonly IProductDAL _ProductDAL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly KeyInvalidate _KeyInvalidate; private readonly BaseEntityAppService _BaseEntityAppService; private readonly ILogger _Logger; public ProductBLL( IProductDAL productDAL, AutoNumber autoNumber, IValidation validation, KeyInvalidate keyInvalidate, BaseEntityAppService baseEntityAppService, ILogger logger) { _ProductDAL = productDAL; _AutoNumber = autoNumber; _Validation = validation; _KeyInvalidate = keyInvalidate; _BaseEntityAppService = baseEntityAppService; _Logger = logger; } public async Task GetProduct(int productId, LoginDTO loginDTO, CancellationToken ct) { try { return await _ProductDAL.GetProduct(productId, loginDTO, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-product-failed", ex); _Logger.LogError(ex, "GetProduct failed for ProductId {ProductId}", productId); throw; } } /// /// Deterministic label rule (no AI/ML) — mirrors ICPBLL.ComputeABCDCategory's threshold logic, /// with product-appropriate labels. avgEffort = average(DeliveryEffortScore, /// OperationalEffortScore), on a 1-10 scale — the two effort-shaped scores this entity carries, /// same pairing pattern as ICP's LeadGenEffortScore+SalesConversionEffortScore average. /// Threshold = midpoint of the scale (5): avgEffort <= 5 is "low" effort, > 5 is "high". /// Same threshold applied to ReturnScore alone: <= 5 is "low" return, > 5 is "high". /// low effort + low return -> Easy-to-Buy (cheap to fulfil, low payoff — fine as a filler) /// high effort + high return -> Primary (hard to fulfil, but the main profit driver) /// low effort + high return -> High-Value (cheap to fulfil AND profitable — best case) /// high effort + low return -> Time-Waster (hard to fulfil and low payoff — avoid/deprioritize) /// public static string ComputeProductLabel(byte deliveryEffortScore, byte operationalEffortScore, byte returnScore) { double avgEffort = (deliveryEffortScore + operationalEffortScore) / 2.0; bool highEffort = avgEffort > 5; bool highReturn = returnScore > 5; return (highEffort, highReturn) switch { (false, false) => "Easy-to-Buy", (true, true) => "Primary", (false, true) => "High-Value", (true, false) => "Time-Waster" }; } public async Task SaveProduct(ProductDTO productDTO, LoginDTO loginDTO, CancellationToken ct) { if (productDTO == null) throw new ArgumentNullException(nameof(productDTO)); AutoNumberDTO? autoNumberDTO = null; try { GB5Trace.Step("validate-product", new { productDTO.ProductId }); await _Validation.NotEmpty(productDTO.ProductCode, nameof(productDTO.ProductCode)); await _Validation.NotEmpty(productDTO.ProductName, nameof(productDTO.ProductName)); await _Validation.Range((byte)1, (byte)10, productDTO.DeliveryEffortScore, nameof(productDTO.DeliveryEffortScore)); await _Validation.Range((byte)1, (byte)10, productDTO.ReturnScore, nameof(productDTO.ReturnScore)); await _Validation.Range((byte)1, (byte)10, productDTO.OperationalEffortScore, nameof(productDTO.OperationalEffortScore)); await _Validation.Range((byte)0, (byte)5, productDTO.SourceType, nameof(productDTO.SourceType)); productDTO.ProductLabel = ComputeProductLabel(productDTO.DeliveryEffortScore, productDTO.OperationalEffortScore, productDTO.ReturnScore); productDTO.ModifiedById = loginDTO.UserId; productDTO.ModifiedOn = DateTime.UtcNow; bool isNew = productDTO.ProductId == 0; if (isNew) { autoNumberDTO = await _AutoNumber.GetNumberAsync(1, "MKTPRODUCT", loginDTO).ConfigureAwait(false); productDTO.ProductId = autoNumberDTO.StartNumber; productDTO.CreatedById = loginDTO.UserId; productDTO.CreatedOn = DateTime.UtcNow; } GB5Trace.Step("save-product", new { productDTO.ProductId, isNew, productDTO.ProductLabel }); await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTMARKETINGPRODUCT, isNew ? EventTypeConstant.SAVEMARKETINGPRODUCTEVENTTYPEID : EventTypeConstant.UPDATEMARKETINGPRODUCTEVENTTYPEID, productDTO, loginDTO, async tx => { _ = isNew ? await _ProductDAL.SaveProduct(productDTO, loginDTO, tx, ct).ConfigureAwait(false) : await _ProductDAL.UpdateProduct(productDTO, loginDTO, tx, ct).ConfigureAwait(false); return productDTO.ProductId; }, null, -1, -1, null, isNew).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = isNew ? EventTypeConstant.SAVEMARKETINGPRODUCTEVENTTYPEID : EventTypeConstant.UPDATEMARKETINGPRODUCTEVENTTYPEID }); var cacheKey = new CacheKeyGeneration().KeyGeneration( productDTO.ProductId, EntityConstant.OBJECTMARKETINGPRODUCT, CacheKeyLevel.CLIENT_LEVEL, loginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation("Product {ProductId} {Action} by user {UserId}", productDTO.ProductId, isNew ? "saved" : "updated", loginDTO.UserId); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {productDTO.ProductId}" : $"{SuccessResponse.UpdateSuccessMessage} {productDTO.ProductId}"; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-product-failed", vex); if (autoNumberDTO != null && productDTO.ProductId > 0) await _AutoNumber.RollbackAutoNumber("MKTPRODUCT", productDTO.ProductId, loginDTO).ConfigureAwait(false); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-product-failed", ex); _Logger.LogError(ex, "SaveProduct failed for ProductId {ProductId}", productDTO.ProductId); if (autoNumberDTO != null && productDTO.ProductId > 0) await _AutoNumber.RollbackAutoNumber("MKTPRODUCT", productDTO.ProductId, loginDTO).ConfigureAwait(false); throw; } } public async Task GetSelectListProduct(CriteriaDTO criteriaDTO, LoginDTO loginDTO, CancellationToken ct) { try { return await _ProductDAL.GetSelectListProduct(criteriaDTO, loginDTO, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-selectlist-product-failed", ex); _Logger.LogError(ex, "GetSelectListProduct failed"); throw; } } public async Task DeleteProduct(int productId, LoginDTO loginDTO, CancellationToken ct) { try { GB5Trace.Step("delete-product", new { productId }); var result = await _ProductDAL.DeleteProduct(productId, loginDTO, ct).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( productId, EntityConstant.OBJECTMARKETINGPRODUCT, CacheKeyLevel.CLIENT_LEVEL, loginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation("Product {ProductId} deleted by user {UserId}", productId, loginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("delete-product-failed", ex); _Logger.LogError(ex, "DeleteProduct failed for ProductId {ProductId}", productId); throw; } } }