using FMBLL.ScanEvent; using FMBLL.ScanIdentifier; using FMDAL.DTO.ScanEvent; using FMDAL.DTO.ScanIntake; using GB5Shared.DTO.Framework.Login; using GB5Shared.ScanEvent; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using System; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace FMBLL.Handlers { // The "Capture -> Identity Resolution -> Scan Event Engine -> Module Handlers" entry point // (RAM_Modernization_Design_Reference.md §4.13): resolves a raw scanned value against // MSCANIDENTIFIER, always records a TSCANEVENT row (whether resolved or not), then — for a // resolved entity — delegates to the platform IScanEventDispatcher, which routes to whichever // module's IScanEventHandler owns that EntityType (e.g. FM's AssetScanEventHandler for MASSET). // // Scope, matching what IScanEventHandler.cs's own header already documents as NOT yet built: // no phantom-read/duplicate filtering (ScanResult is only ever ACCEPTED or UNRESOLVED here — // REJECTED/DUPLICATE are reserved for that later increment) and no zone RSSI validation. // IDENTIFY/VERIFY intents call ResolveContextAsync (read-only); TRANSACT calls HandleScanAsync // (posts to the ledger, with any custody/reference context supplied); AUDIT calls the // dedicated RecordAttestationAsync (posts a presence-only attestation row). public class ScanIntakeBLL : IScanIntakeBLL { private const byte SCANINTENT_TRANSACT = 2; private const byte SCANINTENT_AUDIT = 3; private const byte SCANRESULT_ACCEPTED = 0; private const byte SCANRESULT_UNRESOLVED = 3; private readonly IScanIdentifierBLL _scanIdentifierBll; private readonly IScanEventBLL _scanEventBll; private readonly IScanEventDispatcher _dispatcher; private readonly ILogger _logger; public ScanIntakeBLL( IScanIdentifierBLL scanIdentifierBll, IScanEventBLL scanEventBll, IScanEventDispatcher dispatcher, ILogger logger) { _scanIdentifierBll = scanIdentifierBll; _scanEventBll = scanEventBll; _dispatcher = dispatcher; _logger = logger; } public async Task SubmitScanAsync(ScanSubmitRequestDTO request, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("scan-intake-resolve-identity", new { request.RawScannedValue }); var identifier = await _scanIdentifierBll.GetScanIdentifierByValue(request.RawScannedValue, login, ct).ConfigureAwait(false); var scanEventDto = new ScanEventDTO { OUId = identifier?.OUId ?? login.WorkOUId, ScanSessionId = request.ScanSessionId, ScanIdentifierId = identifier?.ScanIdentifierId, RawScannedValue = request.RawScannedValue, IdentifierType = request.IdentifierType, ScanDeviceId = request.ScanDeviceId, ScanZoneId = request.ScanZoneId, OperatorId = login.UserId, EntityType = identifier?.EntityType, EntityId = identifier?.EntityId, ScanIntent = request.ScanIntent, ScanResult = identifier is null ? SCANRESULT_UNRESOLVED : SCANRESULT_ACCEPTED, Rssi = request.Rssi, DeviceTimestamp = request.DeviceTimestamp ?? DateTime.UtcNow }; await _scanEventBll.SaveScanEvent(scanEventDto, login, ct).ConfigureAwait(false); var result = new ScanSubmitResultDTO { ScanEventId = scanEventDto.ScanEventId, ScanResult = scanEventDto.ScanResult, EntityType = scanEventDto.EntityType, EntityId = scanEventDto.EntityId }; if (identifier is null) { result.Message = $"Scanned value '{request.RawScannedValue}' is not registered to any entity."; return result; } var entity = new TrackableEntity { EntityType = identifier.EntityType, EntityId = identifier.EntityId, OUId = identifier.OUId, TenantId = login.ClientId }; var platformScanEvent = new GB5Shared.ScanEvent.ScanEvent { ScanEventId = scanEventDto.ScanEventId, ScanSessionId = scanEventDto.ScanSessionId, RawScannedValue = scanEventDto.RawScannedValue, IdentifierType = scanEventDto.IdentifierType, ScanZoneId = scanEventDto.ScanZoneId, ScanDeviceId = scanEventDto.ScanDeviceId, OperatorId = scanEventDto.OperatorId, ScanIntent = scanEventDto.ScanIntent }; if (request.ScanIntent == SCANINTENT_TRANSACT) { if (request.ConfirmedBizTransactionTypeId is null || request.ConfirmedBizTransactionTypeId <= 0) { result.Message = "ConfirmedBizTransactionTypeId is required for a TRANSACT-intent scan."; return result; } // Single-DTO boundary (per this repo's own convention for mutating BLL calls) — // built once here from the request's optional fields, never threaded through as // loose scalars. var context = new GB5Shared.ScanEvent.ScanTransactionContext { ToCustodianType = request.ToCustodianType, ToCustodianId = request.ToCustodianId, ReferenceType = request.ReferenceType, ReferenceId = request.ReferenceId, ReferenceNumber = request.ReferenceNumber, ReferenceDate = request.ReferenceDate }; GB5Trace.Step("scan-intake-handle-scan", new { entity.EntityType, entity.EntityId }); var scanResult = await _dispatcher.HandleScanAsync( entity, platformScanEvent, request.ConfirmedBizTransactionTypeId.Value, context, login).ConfigureAwait(false); result.ActionExecuted = true; result.ActionSuccess = scanResult.IsSuccess; result.AssetLedgerId = scanResult.AssetLedgerId; result.Message = scanResult.Message; } else if (request.ScanIntent == SCANINTENT_AUDIT) { GB5Trace.Step("scan-intake-record-attestation", new { entity.EntityType, entity.EntityId }); var scanResult = await _dispatcher.RecordAttestationAsync(entity, platformScanEvent, login).ConfigureAwait(false); result.ActionExecuted = true; result.ActionSuccess = scanResult.IsSuccess; result.AssetLedgerId = scanResult.AssetLedgerId; result.Message = scanResult.Message; } else { GB5Trace.Step("scan-intake-resolve-context", new { entity.EntityType, entity.EntityId }); var context = await _dispatcher.ResolveContextAsync(entity, platformScanEvent, login).ConfigureAwait(false); result.CurrentStateCode = context.CurrentStateCode; result.AllowedTransitionStateCodes = context.AllowedTransitionStateCodes.ToList(); result.Message = context.Found ? "Context resolved." : "Entity context not found."; } return result; } catch (Exception ex) { GB5Trace.MarkFailed("scan-intake-failed", ex); _logger.LogError(ex, "SubmitScanAsync failed for RawScannedValue {RawScannedValue}", request.RawScannedValue); throw; } } } }