using AdminBLL.BIZTransactionType; using AdminDAL.DTO.BIZTransactionType; using FMBLL.Asset; using FMBLL.AssetLedger; using FMBLL.AssetState; using FMBLL.ScanZone; using FMDAL.DTO.Asset; using FMDAL.DTO.AssetLedger; using FMDAL.DTO.AssetState; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.QueryExecutor; using GB5Shared.ScanEvent; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using static GB5Shared.GB5Constant.Constant; namespace FMBLL.Handlers { // FM's IScanEventHandler for MASSET — the module-owned business logic the platform-side // IScanEventDispatcher (GB5Shared/ScanEvent) delegates to once it resolves a scan's // EntityType/EntityId. Per the interface's own documented scope, phantom-read filtering and // zone/RSSI validation happen upstream of this handler; state-transition legality enforcement // is NOT yet implemented anywhere else, so it happens here. // // Custody transfer, AUDIT-intent attestation, and BIN-transaction reference linking (see // /Users/venkatv/.claude/plans/in-legacy-please-refer-deep-codd.md) are handled as follows: // - ToCustodianType/Id come from the caller's ScanTransactionContext when supplied, falling // back to the asset's current custodian (unchanged) otherwise. // - ReferenceType/Id/Number/Date pass straight through from the context onto the ledger row // — RAM never validates these against a live external (e.g. MM) document; it only stores // what the caller supplied (standalone-with-loose-links, per the approved architecture). // - AllowedBizTransactionTypeIds is resolved for real via MASSETSTATE.TRIGGERBIZTRANSACTIONCLASSID // (a CSV of BIZTRANSACTIONCLASSID) + AdminBLL.IBIZTransactionTypeBLL, which is per-tenant // configured data — never hardcoded here (mirrors the AssetActivitySchedulerJob precedent). // - A target state with ISLEDGERENTRYREQUIRED = 0 skips the TASSETLEDGER insert entirely // (snapshot-only) — the escape hatch for high-frequency, low-audit-value transitions // (cleaning-queue, reservation) that would otherwise bloat the ledger. public class AssetScanEventHandler : IScanEventHandler { private readonly IAssetBLL _assetBll; private readonly IAssetStateBLL _assetStateBll; private readonly IAssetLedgerBLL _assetLedgerBll; private readonly IScanZoneBLL _scanZoneBll; private readonly IBIZTransactionTypeBLL _bizTransactionTypeBll; private readonly IQueryExecutor _queryExecutor; private readonly KeyInvalidate _keyInvalidate; private readonly ILogger _logger; public IEnumerable SupportedEntityTypes => new[] { "MASSET" }; public AssetScanEventHandler( IAssetBLL assetBll, IAssetStateBLL assetStateBll, IAssetLedgerBLL assetLedgerBll, IScanZoneBLL scanZoneBll, IBIZTransactionTypeBLL bizTransactionTypeBll, IQueryExecutor queryExecutor, KeyInvalidate keyInvalidate, ILogger logger) { _assetBll = assetBll; _assetStateBll = assetStateBll; _assetLedgerBll = assetLedgerBll; _scanZoneBll = scanZoneBll; _bizTransactionTypeBll = bizTransactionTypeBll; _queryExecutor = queryExecutor; _keyInvalidate = keyInvalidate; _logger = logger; } // NOTE: this module also has its own FMBLL.ScanEvent namespace (TSCANEVENT CRUD), which // shadows GB5Shared.ScanEvent.ScanEvent for unqualified lookup here (nested-namespace // member resolution beats any using/using-alias) — the parameter type must stay fully // qualified rather than a bare "ScanEvent". public async Task ResolveContextAsync(TrackableEntity entity, GB5Shared.ScanEvent.ScanEvent scanEvent, LoginDTO login) { if (!int.TryParse(entity.EntityId, out var assetId)) return new ScanContext { Found = false }; var asset = await GetAssetAsync(assetId, login).ConfigureAwait(false); if (asset is null) return new ScanContext { Found = false }; if (asset.CurrentAssetStateId is null) { // State machine not enabled for this asset's type — still a valid scan target // (e.g. AUDIT-intent stock verification), just no state transitions to offer. return new ScanContext { Found = true }; } var states = (await _assetStateBll.GetAssetStateListByType(asset.AssetTypeId, login, default).ConfigureAwait(false)).ToList(); var currentState = states.FirstOrDefault(s => s.AssetStateId == asset.CurrentAssetStateId); var allowedCodes = ParseCsv(currentState?.AssetStateAllowedTransitions); var candidateStates = states .Where(s => allowedCodes.Contains(s.AssetStateCode, StringComparer.OrdinalIgnoreCase)) .ToList(); var allowedBizTransactionTypeIds = await ResolveAllowedBizTransactionTypeIdsAsync(candidateStates, entity.OUId, login).ConfigureAwait(false); return new ScanContext { Found = true, CurrentStateCode = currentState?.AssetStateCode, AllowedTransitionStateCodes = allowedCodes.ToList(), AllowedBizTransactionTypeIds = allowedBizTransactionTypeIds }; } // Advisory only (per ScanContext's own docs) — aggregates configured BizTransactionTypes // across every candidate next-state's TRIGGERBIZTRANSACTIONCLASSID, per tenant/OU. A class // with no configured type for this tenant/OU contributes nothing (graceful degradation — // mirrors AssetActivitySchedulerJob's "skip, don't guess" precedent); never hardcoded here. private async Task> ResolveAllowedBizTransactionTypeIdsAsync( IEnumerable candidateStates, int ouId, LoginDTO login) { var classIds = candidateStates .SelectMany(s => ParseCsv(s.AssetStateTriggerBizTransactionClassId)) .Select(code => int.TryParse(code, out var id) ? id : (int?)null) .Where(id => id.HasValue) .Select(id => id!.Value) .Distinct() .ToList(); if (classIds.Count == 0) return Array.Empty(); var typeIds = new List(); foreach (var classId in classIds) { var criteria = new CriteriaDTO { SectionCriteriaList = new List { new SectionCriteriaDTO { AttributesCriteriaList = new List { new AttributesCriteriaDTO { FieldName = "BIZTransactionTypeClassId", OperationType = CriteriaDTO.OperationType.Equal, FieldValue = classId } } } } }; var json = await _bizTransactionTypeBll.GetBizTransactionTypeList(criteria, ouId, login).ConfigureAwait(false); var types = string.IsNullOrWhiteSpace(json) ? new List() : JsonConvert.DeserializeObject>(json) ?? new(); typeIds.AddRange(types.Select(t => t.Id)); } return typeIds.Distinct().ToList(); } public async Task HandleScanAsync( TrackableEntity entity, GB5Shared.ScanEvent.ScanEvent scanEvent, int confirmedBizTransactionTypeId, GB5Shared.ScanEvent.ScanTransactionContext? context, LoginDTO login) { try { if (!int.TryParse(entity.EntityId, out var assetId)) return ScanResult.Failure($"Invalid MASSET EntityId '{entity.EntityId}'."); GB5Trace.Step("resolve-asset-for-scan", new { assetId }); var asset = await GetAssetAsync(assetId, login).ConfigureAwait(false); if (asset is null) return ScanResult.Failure($"Asset {assetId} not found."); int? fromStateId = asset.CurrentAssetStateId; int? toStateId = null; AssetStateDTO? toState = null; if (fromStateId.HasValue) { var states = (await _assetStateBll.GetAssetStateListByType(asset.AssetTypeId, login, default).ConfigureAwait(false)).ToList(); var currentState = states.FirstOrDefault(s => s.AssetStateId == fromStateId.Value); if (currentState is null) return ScanResult.Failure($"Asset {assetId}'s current state {fromStateId} is not configured for AssetType {asset.AssetTypeId}."); var allowedCodes = ParseCsv(currentState.AssetStateAllowedTransitions); var candidates = states .Where(s => allowedCodes.Contains(s.AssetStateCode, StringComparer.OrdinalIgnoreCase)) .Where(s => ParseCsv(s.AssetStateTriggerZoneId).Contains(scanEvent.ScanZoneId.ToString())) .ToList(); if (candidates.Count == 0) return ScanResult.Failure( $"No configured transition from state '{currentState.AssetStateCode}' is triggered by zone {scanEvent.ScanZoneId}."); if (candidates.Count > 1) return ScanResult.Failure( $"Ambiguous transition: zone {scanEvent.ScanZoneId} triggers {candidates.Count} candidate states from '{currentState.AssetStateCode}'."); toState = candidates[0]; toStateId = toState.AssetStateId; } var scanZone = await _scanZoneBll.GetScanZone(scanEvent.ScanZoneId, login, default).ConfigureAwait(false); var toLocationId = scanZone?.PartyBranchLocationId ?? asset.CurrentLocationId; var newCycleCount = asset.CycleCount + 1; // Custody: use the caller-supplied target when given, else unchanged (today's fallback). var toCustodianType = context?.ToCustodianType ?? asset.CurrentCustodianType; var toCustodianId = context?.ToCustodianId ?? asset.CurrentCustodianId; var ledgerDto = new AssetLedgerDTO { OUId = entity.OUId, AssetId = assetId, BizTransactionTypeId = confirmedBizTransactionTypeId, AssetLedgerDate = DateTime.UtcNow.Date, FromStateId = fromStateId, ToStateId = toStateId, FromCustodianType = asset.CurrentCustodianType, FromCustodianId = asset.CurrentCustodianId, ToCustodianType = toCustodianType, ToCustodianId = toCustodianId, FromLocationId = asset.CurrentLocationId, ToLocationId = toLocationId, ScanEventId = scanEvent.ScanEventId, ScanSessionId = scanEvent.ScanSessionId, ReferenceType = context?.ReferenceType, ReferenceId = context?.ReferenceId, ReferenceNumber = context?.ReferenceNumber, ReferenceDate = context?.ReferenceDate, CycleCount = newCycleCount, OperatorId = scanEvent.OperatorId }; // A target state configured with ISLEDGERENTRYREQUIRED = 0 (e.g. cleaning-queue, // reservation) skips the TASSETLEDGER insert entirely — snapshot updates only, to // keep the ledger free of high-frequency, low-audit-value rows. No target state // (fromStateId was null — state machine not enabled) always requires the ledger, // since there's no state config to say otherwise. var requiresLedgerEntry = toState is null || toState.AssetStateIsLedgerEntryRequired != 0; await using var tx = await _queryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); int? ledgerId = null; try { GB5Trace.Step("post-asset-ledger-and-snapshot", new { assetId, fromStateId, toStateId, requiresLedgerEntry }); if (requiresLedgerEntry) ledgerId = await _assetLedgerBll.SaveAssetLedgerInTransaction(ledgerDto, login, tx, default).ConfigureAwait(false); var snapshot = new AssetSnapshotDTO { AssetId = assetId, CurrentLocationId = ledgerDto.ToLocationId, CurrentCustodianType = ledgerDto.ToCustodianType, CurrentCustodianId = ledgerDto.ToCustodianId, CurrentAssetStateId = ledgerDto.ToStateId, LastMovementOn = DateTime.UtcNow, CycleCount = newCycleCount }; await _assetBll.UpdateAssetSnapshot(snapshot, login, tx, default).ConfigureAwait(false); await tx.CommitAsync(default).ConfigureAwait(false); } catch { await tx.RollbackAsync(default).ConfigureAwait(false); throw; } var cacheKey = new CacheKeyGeneration().KeyGeneration(assetId, EntityConstant.OBJECTASSET, CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); return ScanResult.Success( requiresLedgerEntry ? $"Asset {assetId} ledger posted." : $"Asset {assetId} snapshot updated (no ledger entry required for this transition).", ledgerId); } catch (Exception ex) { GB5Trace.MarkFailed("asset-scan-handle-failed", ex); _logger.LogError(ex, "AssetScanEventHandler.HandleScanAsync failed for EntityId {EntityId}", entity.EntityId); return ScanResult.Failure($"Scan handling failed: {ex.Message}"); } } // AUDIT-intent presence verification — no state/location/custody transition, no operator- // confirmed BizTransactionType. Always posts a TASSETLEDGER row (the record itself is the // value here) with BizTransactionTypeId = -1 (this codebase's "None/Not Applicable" // sentinel — attestation is not a business transaction, so no type applies). public async Task RecordAttestationAsync(TrackableEntity entity, GB5Shared.ScanEvent.ScanEvent scanEvent, LoginDTO login) { try { if (!int.TryParse(entity.EntityId, out var assetId)) return ScanResult.Failure($"Invalid MASSET EntityId '{entity.EntityId}'."); GB5Trace.Step("resolve-asset-for-attestation", new { assetId }); var asset = await GetAssetAsync(assetId, login).ConfigureAwait(false); if (asset is null) return ScanResult.Failure($"Asset {assetId} not found."); var newCycleCount = asset.CycleCount + 1; var ledgerDto = new AssetLedgerDTO { OUId = entity.OUId, AssetId = assetId, BizTransactionTypeId = -1, AssetLedgerDate = DateTime.UtcNow.Date, FromStateId = asset.CurrentAssetStateId, ToStateId = asset.CurrentAssetStateId, FromCustodianType = asset.CurrentCustodianType, FromCustodianId = asset.CurrentCustodianId, ToCustodianType = asset.CurrentCustodianType, ToCustodianId = asset.CurrentCustodianId, FromLocationId = asset.CurrentLocationId, ToLocationId = asset.CurrentLocationId, ScanEventId = scanEvent.ScanEventId, ScanSessionId = scanEvent.ScanSessionId, CycleCount = newCycleCount, OperatorId = scanEvent.OperatorId, Remarks = "AUDIT: physical presence confirmed" }; await using var tx = await _queryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); int ledgerId; try { GB5Trace.Step("post-audit-attestation", new { assetId }); ledgerId = await _assetLedgerBll.SaveAssetLedgerInTransaction(ledgerDto, login, tx, default).ConfigureAwait(false); var snapshot = new AssetSnapshotDTO { AssetId = assetId, CurrentLocationId = ledgerDto.ToLocationId, CurrentCustodianType = ledgerDto.ToCustodianType, CurrentCustodianId = ledgerDto.ToCustodianId, CurrentAssetStateId = ledgerDto.ToStateId, LastMovementOn = DateTime.UtcNow, CycleCount = newCycleCount }; await _assetBll.UpdateAssetSnapshot(snapshot, login, tx, default).ConfigureAwait(false); await tx.CommitAsync(default).ConfigureAwait(false); } catch { await tx.RollbackAsync(default).ConfigureAwait(false); throw; } var cacheKey = new CacheKeyGeneration().KeyGeneration(assetId, EntityConstant.OBJECTASSET, CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); return ScanResult.Success($"Asset {assetId} presence attested.", ledgerId); } catch (Exception ex) { GB5Trace.MarkFailed("asset-attestation-failed", ex); _logger.LogError(ex, "AssetScanEventHandler.RecordAttestationAsync failed for EntityId {EntityId}", entity.EntityId); return ScanResult.Failure($"Attestation failed: {ex.Message}"); } } private async Task GetAssetAsync(int assetId, LoginDTO login) { var json = await _assetBll.GetAsset(assetId, login).ConfigureAwait(false); return string.IsNullOrWhiteSpace(json) ? null : JsonConvert.DeserializeObject(json); } private static HashSet ParseCsv(string? csv) => csv is null ? new HashSet(StringComparer.OrdinalIgnoreCase) : new HashSet(csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), StringComparer.OrdinalIgnoreCase); } }