using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Threading.Tasks; using GB5Shared.DTO.Framework.Login; using Microsoft.Extensions.DependencyInjection; namespace GB5Shared.ScanEvent { // ───────────────────────────────────────────────────────────────────── // RAM Phase 2 — platform-side scan/event-engine framework. // Reference: RAM_Modernization_Design_Reference.md §4.13. // // This layer is module-agnostic: it resolves identity, exposes context, // and dispatches to whichever module owns the scanned entity. It does // NOT contain module business logic (that lives in each module's own // IScanEventHandler implementation, built alongside that module's own // RAM touchpoint in a later phase — FM/FAM in Phase 3, CRM-Service in // Phase 4, per the phased migration plan). It also does NOT yet // implement phantom-read filtering, zone RSSI validation, or configured // state-machine transition enforcement — those are Scan Event Engine // concerns layered on top of this extension point in a later increment. // ───────────────────────────────────────────────────────────────────── /// /// A resolved reference to the entity a scan identifier points at — /// mirrors MSCANIDENTIFIER/TSCANEVENT's generic (ENTITYTYPE, ENTITYID) /// bridge. EntityId is a string for the same reason MSCANIDENTIFIER's /// column is: the bridge must span mixed-key-type targets in principle, /// even though the two currently-known targets (MASSET.ASSETID, /// MBIN.BINID) both happen to be INT. /// public sealed class TrackableEntity { public required string EntityType { get; init; } // e.g. "MASSET", "MBIN" public required string EntityId { get; init; } public int OUId { get; init; } public int TenantId { get; init; } } /// /// The scan being processed — a lightweight projection of one TSCANEVENT /// row, not the row itself (module handlers should not depend on the /// DAL-owned TSCANEVENT shape). /// public sealed class ScanEvent { public int ScanEventId { get; init; } public int ScanSessionId { get; init; } public string RawScannedValue { get; init; } = string.Empty; public byte IdentifierType { get; init; } // (0.RFID_EPC,1.RFID_TID,2.QR,3.BARCODE,4.NFC,5.MANUAL) public int ScanZoneId { get; init; } public int ScanDeviceId { get; init; } public int OperatorId { get; init; } public byte ScanIntent { get; init; } // (0.IDENTIFY,1.VERIFY,2.TRANSACT,3.AUDIT) } /// /// What a module reports back after ResolveContextAsync — current /// state/position plus what the platform should offer the operator next. /// AllowedBizTransactionTypeIds/AllowedTransitionStateCodes are advisory /// to the caller (e.g. a mobile scan UI); enforcement of an actually /// chosen transition happens in HandleScanAsync. /// public sealed class ScanContext { public string? CurrentStateCode { get; init; } public IReadOnlyList AllowedTransitionStateCodes { get; init; } = []; public IReadOnlyList AllowedBizTransactionTypeIds { get; init; } = []; public bool Found { get; init; } } /// Outcome of HandleScanAsync — success/failure plus the ledger row it produced, if any. public sealed class ScanResult { public bool IsSuccess { get; init; } public string Message { get; init; } = string.Empty; public int? AssetLedgerId { get; init; } // set when a TASSETLEDGER row was posted public static ScanResult Success(string message, int? assetLedgerId = null) => new() { IsSuccess = true, Message = message, AssetLedgerId = assetLedgerId }; public static ScanResult Failure(string message) => new() { IsSuccess = false, Message = message }; } /// /// What the operator/caller decided to do with a TRANSACT-intent scan — distinct from /// ScanEvent (which is *what was scanned*). All fields optional: a handler falls back to /// its own default behavior (e.g. custody unchanged) when a field isn't supplied, so /// handlers that never need custody/reference tracking aren't forced to populate this. /// public sealed class ScanTransactionContext { /// Target custodian type (0.INTERNAL_OU,1.EMPLOYEE,2.PARTNER,3.CUSTOMER) — null keeps custody unchanged. public int? ToCustodianType { get; init; } public int? ToCustodianId { get; init; } /// e.g. "TMMHEAD", "TPRODUCTION" — set only when a live external document exists to reference. public string? ReferenceType { get; init; } public int? ReferenceId { get; init; } /// Free-text fallback for tenants with no live document to point ReferenceId at. public string? ReferenceNumber { get; init; } public DateTime? ReferenceDate { get; init; } } /// /// Implemented once per owning module (FM, FAM, Maintenance, CRM-Service, ...). /// The platform's dispatcher resolves the right handler by EntityType and /// delegates — no module ever needs to know about any other module's /// entity types. /// public interface IScanEventHandler { /// Entity-type strings this handler owns, e.g. ["MASSET"]. IEnumerable SupportedEntityTypes { get; } /// Resolve current state/allowed-next-actions for a scanned/identified entity. Task ResolveContextAsync(TrackableEntity entity, ScanEvent scanEvent, LoginDTO login); /// /// Execute the business action after platform-level validation (identity /// resolution, phantom-read filtering, zone/intent checks) has already /// passed. confirmedBizTransactionType is the operator-confirmed choice /// from among ScanContext.AllowedBizTransactionTypeIds. context carries any /// custody-target/reference-document details the operator supplied — null when none apply. /// Task HandleScanAsync( TrackableEntity entity, ScanEvent scanEvent, int confirmedBizTransactionTypeId, ScanTransactionContext? context, LoginDTO login); /// /// Record an AUDIT-intent scan (physical presence verification) — no state/location/ /// custody transition, no operator-confirmed BizTransactionType. Distinct from /// HandleScanAsync so AUDIT never has to overload confirmedBizTransactionTypeId with a /// synthetic/sentinel value to mean "nothing moved." /// Task RecordAttestationAsync(TrackableEntity entity, ScanEvent scanEvent, LoginDTO login); } /// /// Resolves and invokes the right IScanEventHandler for a scanned entity's /// EntityType. Handlers are looked up by string key (not by C# generic /// type, unlike IListDispatcher) because EntityType is only known at /// runtime — it comes off the MSCANIDENTIFIER/TSCANEVENT row, not off a /// compile-time query type. /// public interface IScanEventDispatcher { Task ResolveContextAsync(TrackableEntity entity, ScanEvent scanEvent, LoginDTO login); Task HandleScanAsync( TrackableEntity entity, ScanEvent scanEvent, int confirmedBizTransactionTypeId, ScanTransactionContext? context, LoginDTO login); Task RecordAttestationAsync(TrackableEntity entity, ScanEvent scanEvent, LoginDTO login); } /// /// Builds an EntityType → handler lookup once from every registered /// IScanEventHandler (DI is the registry — no manual switch statements, /// same principle as ListDispatcher). /// public sealed class ScanEventDispatcher : IScanEventDispatcher { private readonly Lazy> _byEntityType; public ScanEventDispatcher(IEnumerable handlers) { _byEntityType = new Lazy>(() => { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var handler in handlers) foreach (var entityType in handler.SupportedEntityTypes) { if (map.ContainsKey(entityType)) throw new InvalidOperationException( $"Multiple IScanEventHandler registrations claim EntityType '{entityType}'."); map[entityType] = handler; } return map; }); } public Task ResolveContextAsync(TrackableEntity entity, ScanEvent scanEvent, LoginDTO login) => Resolve(entity.EntityType).ResolveContextAsync(entity, scanEvent, login); public Task HandleScanAsync( TrackableEntity entity, ScanEvent scanEvent, int confirmedBizTransactionTypeId, ScanTransactionContext? context, LoginDTO login) => Resolve(entity.EntityType).HandleScanAsync(entity, scanEvent, confirmedBizTransactionTypeId, context, login); public Task RecordAttestationAsync(TrackableEntity entity, ScanEvent scanEvent, LoginDTO login) => Resolve(entity.EntityType).RecordAttestationAsync(entity, scanEvent, login); private IScanEventHandler Resolve(string entityType) => _byEntityType.Value.TryGetValue(entityType, out var handler) ? handler : throw new InvalidOperationException( $"No IScanEventHandler registered for EntityType '{entityType}'. " + $"Register an IScanEventHandler whose SupportedEntityTypes includes it."); } /// DI registration helper — call once in Program.cs per module hosting scan handlers. public static class ScanEventServiceExtensions { /// /// Registers IScanEventDispatcher and every IScanEventHandler implementation /// found in . Safe to call from multiple /// modules' Program.cs — each module registers only its own assembly's /// handlers; IScanEventDispatcher itself is process-wide only within a /// single service's DI container (matching this platform's one-module- /// per-process hosting model). /// public static IServiceCollection AddScanEventHandlers( this IServiceCollection services, params Assembly[] scanAssemblies) { services.AddScoped(); if (scanAssemblies is null or { Length: 0 }) return services; foreach (var assembly in scanAssemblies) foreach (var type in assembly.GetTypes() .Where(t => !t.IsAbstract && !t.IsInterface && typeof(IScanEventHandler).IsAssignableFrom(t))) { services.AddScoped(typeof(IScanEventHandler), type); } return services; } } }