using System.Text.Json; using GB5Shared.DTO.Framework.Login; using GB5Shared.QRCode; using GB5Shared.ScanIdentifier; using WiBLL.WiExecution; using WiDAL.CustomCode.WiMaster; using WiDAL.CustomCode.WiScanCode; using WiDAL.DTOs; namespace WiBLL.WiScanCode; public class WiScanCodeBLL : IWiScanCodeBLL { private readonly IWiScanCodeDAL _dal; private readonly IWiExecutionBLL _executionBll; private readonly IWiMasterDAL _masterDal; private readonly IScanIdentifierEngine _scanIdentifierEngine; public WiScanCodeBLL(IWiScanCodeDAL dal, IWiExecutionBLL executionBll, IWiMasterDAL masterDal, IScanIdentifierEngine scanIdentifierEngine) { _dal = dal; _executionBll = executionBll; _masterDal = masterDal; _scanIdentifierEngine = scanIdentifierEngine; } public async Task GenerateScanCode( ScanCodeType codeType, int entityId, int pixelsPerModule, LoginDTO login, CancellationToken ct) { var exists = await _dal.ExistsAsync(codeType, entityId, login, ct).ConfigureAwait(false); if (!exists) throw new InvalidOperationException($"{codeType} {entityId} not found."); string payload = $"{codeType.Prefix()}:{entityId}"; // Bridge: this screen predates the Generic Scan-Identifier & Label Platform and only ever // rendered a PNG with no persisted record — VerifyScan now requires a real MSCANIDENTIFIER // row for WS/EMP/WI, so this call registers one under the hood using the exact same payload // string, keeping this still-shipped screen working until it's replaced by the platform's // own Generate facility. WOD has no resolver yet (TryRegisterGeneratedIdentifier no-ops for // it). Best-effort: a registration failure must not break the PNG the user is looking at. await TryRegisterGeneratedIdentifierAsync(codeType, entityId, payload, login, ct).ConfigureAwait(false); return QrCodeGenerator.GenerateQrPng(payload, pixelsPerModule); } private async Task TryRegisterGeneratedIdentifierAsync( ScanCodeType codeType, int entityId, string payload, LoginDTO login, CancellationToken ct) { var entityType = codeType switch { ScanCodeType.Workstation => "MWORKSTATION", ScanCodeType.Employee => "MEMPLOYEE", ScanCodeType.Wi => "MWI", _ => null // WorkOrderDetail: no IScanTargetResolver registered yet }; if (entityType is null) return; try { var alreadyRegistered = await _scanIdentifierEngine.GetByValueAsync(payload, login, ct).ConfigureAwait(false); if (alreadyRegistered is not null) return; await _scanIdentifierEngine.GenerateAsync( new GenerateScanIdentifierRequestDTO { EntityType = entityType, EntityId = entityId.ToString() }, login, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Shared.Telemetry.GB5Trace.MarkFailed("generatescancode-bridge-register-failed", ex); } } // Unified "scan → instant valid/invalid" path. Consolidates what the Workstation/Employee // scan cards previously did with client-side "{Prefix}:{Id}" parsing plus two different // resolve endpoints, and gives WI/WorkOrderDetail scans (generation-only before this) the // same real verify result the other two already had. public async Task VerifyScan(string scannedValue, LoginDTO login, CancellationToken ct) { var (prefix, entityId) = ParseScannedValue(scannedValue); if (prefix is null || entityId is null) { return Serialize(new WiScanVerifyResultDTO { IsValid = false, Message = "Unrecognized scan format." }); } // WS/EMP/WI now have a real IScanTargetResolver + MSCANTARGETCONFIG registration (Generic // Scan-Identifier & Label Platform) — a well-formed "{Prefix}:{Id}" string that was never // actually generated via that platform, or was later Inactivated, must not verify // successfully just because the entity it names happens to exist. WOD has no resolver yet // (its generation ownership moves to MM in a later phase) — unchanged for now, still a // pure existence+status check. if (prefix != "WOD") { var generated = await _scanIdentifierEngine.GetByValueAsync(scannedValue, login, ct).ConfigureAwait(false); if (generated is null) { return Serialize(new WiScanVerifyResultDTO { IsValid = false, EntityId = entityId.Value, Message = "This code was not generated by the system, or has been inactivated." }); } } var result = prefix switch { "WS" => await VerifyWorkstation(entityId.Value, login, ct).ConfigureAwait(false), "EMP" => await VerifyEmployee(entityId.Value, login, ct).ConfigureAwait(false), "WI" => await VerifyWi(entityId.Value, login, ct).ConfigureAwait(false), "WOD" => await VerifyWorkOrderDetail(entityId.Value, login, ct).ConfigureAwait(false), _ => new WiScanVerifyResultDTO { IsValid = false, EntityId = entityId.Value, Message = $"Unknown scan prefix '{prefix}'." } }; return Serialize(result); } private static (string? Prefix, int? EntityId) ParseScannedValue(string scannedValue) { if (string.IsNullOrWhiteSpace(scannedValue)) return (null, null); var parts = scannedValue.Split(':', 2); if (parts.Length != 2) return (null, null); return int.TryParse(parts[1], out var id) ? (parts[0], id) : (null, null); } private async Task VerifyWorkstation(int entityId, LoginDTO login, CancellationToken ct) { var json = await _executionBll.ResolveForWorkstation(entityId, TriggerEvent.Manual, login, ct).ConfigureAwait(false); var resolved = JsonSerializer.Deserialize(json, JsonOpts); var matches = resolved?.Matches ?? []; var winner = matches.FirstOrDefault(m => m.IsWinner) ?? matches.FirstOrDefault(); return new WiScanVerifyResultDTO { IsValid = winner is not null, EntityType = nameof(ScanCodeType.Workstation), EntityId = entityId, Message = winner is not null ? null : (resolved?.ReasonMessage ?? "No WI resolves for this workstation."), Data = winner is null ? null : new { Match = winner, OtherMatchesCount = matches.Count - 1 } }; } private async Task VerifyEmployee(int entityId, LoginDTO login, CancellationToken ct) { var json = await _executionBll.GetCurrentAssignmentForEmployee(entityId, null, login, ct).ConfigureAwait(false); var assignment = JsonSerializer.Deserialize(json, JsonOpts); return new WiScanVerifyResultDTO { IsValid = assignment is not null, EntityType = nameof(ScanCodeType.Employee), EntityId = entityId, Message = assignment is not null ? null : "No current workstation assignment found for this employee today.", Data = assignment }; } // A "WI:" scan identifies the Work Instruction itself, not something to resolve an // assignment for — valid means the WI exists for this tenant and is Published (a Draft/ // InReview/Approved/Archived WI is real but not yet — or no longer — meant to be scanned // and executed against on the shop floor). private async Task VerifyWi(int entityId, LoginDTO login, CancellationToken ct) { var wi = await _masterDal.GetWiById(entityId, login, ct).ConfigureAwait(false); if (wi is null) { return new WiScanVerifyResultDTO { IsValid = false, EntityType = nameof(ScanCodeType.Wi), EntityId = entityId, Message = "Work Instruction not found." }; } bool isPublished = wi.WiStatus == WiStatus.Published; return new WiScanVerifyResultDTO { IsValid = isPublished, EntityType = nameof(ScanCodeType.Wi), EntityId = entityId, Message = isPublished ? null : $"Work Instruction is {wi.WiStatus}, not Published.", Data = wi }; } private async Task VerifyWorkOrderDetail(int entityId, LoginDTO login, CancellationToken ct) { var data = await _dal.GetWorkOrderDetailVerifyData(entityId, login, ct).ConfigureAwait(false); if (data is null) { return new WiScanVerifyResultDTO { IsValid = false, EntityType = nameof(ScanCodeType.WorkOrderDetail), EntityId = entityId, Message = "Work order detail not found." }; } bool isOpen = data.IndentStatus != 2; // 2 = soft-deleted/cancelled, same sentinel used across this codebase return new WiScanVerifyResultDTO { IsValid = isOpen, EntityType = nameof(ScanCodeType.WorkOrderDetail), EntityId = entityId, Message = isOpen ? null : "This work order is cancelled/closed.", Data = data }; } private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); private static string Serialize(WiScanVerifyResultDTO result) => JsonSerializer.Serialize(result); }