using AccountsDAL.CustomCode.WarehousePostingRule; using AccountsDAL.DTO.WarehousePostingRule; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.HybridCache; using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Validation; using static GB5Shared.GB5Constant.Constant; namespace AccountsBLL.WarehousePostingRule { // Admin CRUD for MWAREHOUSEMEASUREPOSTINGRULE — the highest-priority gap called out in the // GB5 Data Warehouse developer guide: the posting engine (WarehouseFactPostingDAL) has always // been able to READ this table, but until this module there was no way to create/edit/delete a // rule except direct SQL. Enforces the guide's stated validation rules so a bad row can't reach // the DB and confuse the posting engine at run time. public class WarehousePostingRuleBLL : IWarehousePostingRuleBLL { private const string PostingRuleEntityCode = "WAREHOUSEPOSTINGRULE"; private readonly IWarehousePostingRuleDAL _WarehousePostingRuleDAL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly KeyInvalidate _KeyInvalidate; public WarehousePostingRuleBLL( IWarehousePostingRuleDAL WarehousePostingRuleDAL, AutoNumber AutoNumber, IValidation Validation, KeyInvalidate KeyInvalidate) { _WarehousePostingRuleDAL = WarehousePostingRuleDAL; _AutoNumber = AutoNumber; _Validation = Validation; _KeyInvalidate = KeyInvalidate; } public async Task GetWarehousePostingRule(int PostingRuleId, LoginDTO LoginDTO) { try { return await _WarehousePostingRuleDAL.GetWarehousePostingRule(PostingRuleId, LoginDTO); } catch (Exception) { throw; } } public async Task GetWarehousePostingRuleList(int? FactId, LoginDTO LoginDTO) { try { return await _WarehousePostingRuleDAL.GetWarehousePostingRuleList(FactId, LoginDTO); } catch (Exception) { throw; } } public async Task SaveWarehousePostingRule(WarehousePostingRuleDTO WarehousePostingRuleDTO, LoginDTO LoginDTO) { if (WarehousePostingRuleDTO == null) throw new ArgumentNullException(nameof(WarehousePostingRuleDTO)); try { await ValidateRuleAsync(WarehousePostingRuleDTO, LoginDTO).ConfigureAwait(false); WarehousePostingRuleDTO.PostingRuleModifiedById = LoginDTO.UserId; WarehousePostingRuleDTO.PostingRuleModifiedOn = DateTime.UtcNow; int initialId = WarehousePostingRuleDTO.PostingRuleId; if (initialId == 0) { int count = 1; AutoNumberDTO autoNumberDTO = await _AutoNumber.GetNumberAsync(count, PostingRuleEntityCode, LoginDTO); WarehousePostingRuleDTO.PostingRuleId = autoNumberDTO.StartNumber; WarehousePostingRuleDTO.PostingRuleCreatedById = LoginDTO.UserId; WarehousePostingRuleDTO.PostingRuleCreatedOn = DateTime.UtcNow; try { await _WarehousePostingRuleDAL.SaveWarehousePostingRule(WarehousePostingRuleDTO, LoginDTO); } catch { await _AutoNumber.RollbackAutoNumber(PostingRuleEntityCode, WarehousePostingRuleDTO.PostingRuleId, LoginDTO); throw; } await InvalidateCacheAsync(WarehousePostingRuleDTO.PostingRuleId, LoginDTO).ConfigureAwait(false); return $"{SuccessResponse.SaveSuccessMessage} {WarehousePostingRuleDTO.PostingRuleId}"; } else { await _WarehousePostingRuleDAL.UpdateWarehousePostingRule(WarehousePostingRuleDTO, LoginDTO); await InvalidateCacheAsync(WarehousePostingRuleDTO.PostingRuleId, LoginDTO).ConfigureAwait(false); return $"{SuccessResponse.UpdateSuccessMessage} {WarehousePostingRuleDTO.PostingRuleId}"; } } catch (Exception) { throw; } } public async Task DeleteWarehousePostingRule(int PostingRuleId, LoginDTO LoginDTO) { var result = await _WarehousePostingRuleDAL.DeleteWarehousePostingRule(PostingRuleId, LoginDTO); await InvalidateCacheAsync(PostingRuleId, LoginDTO).ConfigureAwait(false); return result; } private async Task InvalidateCacheAsync(int postingRuleId, LoginDTO loginDTO) { var cacheKey = CacheKeyGenerator.KeyGeneration( postingRuleId, EntityConstant.OBJECTWAREHOUSEPOSTINGRULE, CacheKeyLevel.CLIENT_LEVEL, string.Empty, loginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); } // Enforces the "Critical BLL validation rules to enforce on Save" from the GB5 Data // Warehouse developer guide, section 09.1 — surfaces a clear error at save-time instead of // a raw DB constraint violation or a runtime failure inside the posting engine. private async Task ValidateRuleAsync(WarehousePostingRuleDTO dto, LoginDTO loginDTO) { if (dto.MeasureId <= 0) throw new InvalidOperationException("MeasureId is required."); if (dto.SourceType == 0) { // NatureBucketAggregation: NatureColumnType, NatureValues, AggregationMode, and // SignConvention must all be non-null; FormulaExpression must be null. if (dto.NatureColumnType is null || dto.NatureValues is null || dto.AggregationMode is null || dto.SignConvention is null) { throw new InvalidOperationException( "SourceType=0 (NatureBucketAggregation) requires NatureColumnType, NatureValues, AggregationMode, and SignConvention to all be set."); } if (!string.IsNullOrEmpty(dto.FormulaExpression)) throw new InvalidOperationException("SourceType=0 (NatureBucketAggregation) must not set FormulaExpression."); await _Validation.NotEmpty(dto.NatureValues!, nameof(dto.NatureValues)); } else if (dto.SourceType == 1) { // DerivedFormula: FormulaExpression must be non-null and every token must resolve // to a registered MeasureCode belonging to the SAME fact table as this rule's own // measure. Nature/aggregation/sign fields must all be null. if (string.IsNullOrEmpty(dto.FormulaExpression)) throw new InvalidOperationException("SourceType=1 (DerivedFormula) requires FormulaExpression to be set."); if (dto.NatureColumnType is not null || dto.NatureValues is not null || dto.AggregationMode is not null || dto.SignConvention is not null) { throw new InvalidOperationException( "SourceType=1 (DerivedFormula) must not set NatureColumnType, NatureValues, AggregationMode, or SignConvention."); } var validCodes = new HashSet( await _WarehousePostingRuleDAL.GetMeasureCodesForSameFact(dto.MeasureId, loginDTO).ConfigureAwait(false), StringComparer.OrdinalIgnoreCase); var tokens = System.Text.RegularExpressions.Regex.Matches(dto.FormulaExpression!, @"[A-Z0-9_]+") .Select(m => m.Value); foreach (var token in tokens) { if (!validCodes.Contains(token)) throw new InvalidOperationException( $"FORMULAEXPRESSION '{dto.FormulaExpression}' references '{token}', which is not a registered MeasureCode on the same fact table."); } } else { throw new InvalidOperationException($"Unsupported SourceType {dto.SourceType} — must be 0 (NatureBucketAggregation) or 1 (DerivedFormula)."); } // One rule per measure — UK_MWHMEASUREPOSTRULE_MEASUREID enforces this in the DB, but // checked here first for a clear message instead of a raw constraint-violation exception. var existingRuleId = await _WarehousePostingRuleDAL.GetExistingRuleIdForMeasure(dto.MeasureId, loginDTO).ConfigureAwait(false); if (existingRuleId.HasValue && existingRuleId.Value != dto.PostingRuleId) throw new InvalidOperationException( $"MeasureId {dto.MeasureId} already has a posting rule (PostingRuleId {existingRuleId.Value}) — only one rule per measure is allowed."); } } }