using System.Text.Json; using Dapr.Client; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using WiDAL.CustomCode.LineProductionPlanAllocation; using WiDAL.CustomCode.LineRoster; using WiDAL.CustomCode.LineStation; using WiDAL.CustomCode.ProductionLine; using WiDAL.CustomCode.WiAssignment; using WiDAL.CustomCode.WiExecution; using WiDAL.CustomCode.WiMaster; using WiDAL.CustomCode.WiSkillGap; using WiDAL.CustomCode.WiWorkOrder; using WiDAL.DTOs; using WiBLL.WorkstationAvailability; namespace WiBLL.WiSkillGap; public class WiSkillGapBLL : IWiSkillGapBLL { private readonly ILineRosterDAL _lineRosterDal; private readonly ILineProductionPlanAllocationDAL _allocationDal; private readonly IWiSkillGapDAL _skillGapDal; private readonly IWiAssignmentDAL _assignmentDal; private readonly IWiWorkOrderDAL _workOrderDal; private readonly IWiExecutionDAL _executionDal; private readonly IWiMasterDAL _masterDal; private readonly ILineStationDAL _lineStationDal; private readonly IProductionLineDAL _productionLineDal; private readonly IWorkstationAvailabilityBLL _workstationAvailabilityBll; private readonly DaprClient _daprClient; private readonly ILogger _logger; public WiSkillGapBLL( ILineRosterDAL lineRosterDal, ILineProductionPlanAllocationDAL allocationDal, IWiSkillGapDAL skillGapDal, IWiAssignmentDAL assignmentDal, IWiWorkOrderDAL workOrderDal, IWiExecutionDAL executionDal, IWiMasterDAL masterDal, ILineStationDAL lineStationDal, IProductionLineDAL productionLineDal, IWorkstationAvailabilityBLL workstationAvailabilityBll, DaprClient daprClient, ILogger logger) { _lineRosterDal = lineRosterDal; _allocationDal = allocationDal; _skillGapDal = skillGapDal; _assignmentDal = assignmentDal; _workOrderDal = workOrderDal; _executionDal = executionDal; _masterDal = masterDal; _lineStationDal = lineStationDal; _productionLineDal = productionLineDal; _workstationAvailabilityBll = workstationAvailabilityBll; _daprClient = daprClient; _logger = logger; } public async Task GetSkillGapForRoster(int productionLineId, DateOnly rosterDate, int shiftId, LoginDTO login, CancellationToken ct) { var result = await BuildSkillGapResultAsync(productionLineId, rosterDate, shiftId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(result); } // Line Digital Twin — ordered stations for the line, each with its staffed employees/skill-gap // (reusing the same resolution logic as skill-gap-for-roster) and any live in-progress execution. public async Task GetLineDigitalTwin(int productionLineId, DateOnly shiftDate, int shiftId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("line-digital-twin", new { productionLineId, shiftDate, shiftId }); var stationsJson = await _lineStationDal.GetCurrentByLineAsync(productionLineId, login, ct).ConfigureAwait(false); var stations = JsonSerializer.Deserialize>(stationsJson, JsonOpts) ?? []; var skillGap = await BuildSkillGapResultAsync(productionLineId, shiftDate, shiftId, login, ct).ConfigureAwait(false); var employeesByStation = skillGap.Employees .Where(e => e.WorkStationId is not null) .GroupBy(e => e.WorkStationId!.Value) .ToDictionary(g => g.Key, g => g.ToList()); var liveByStation = (await _executionDal.GetActiveExecutionsForWorkstations( stations.Select(s => s.WorkstationId), login, ct).ConfigureAwait(false)) .GroupBy(e => e.WorkStationId) .ToDictionary(g => g.Key, g => g.First()); var lineName = (await _productionLineDal.GetLinesForTenant(login, ct).ConfigureAwait(false)) .FirstOrDefault(l => l.LineId == productionLineId)?.LineName; var now = DateTime.UtcNow; var outageResults = await Task.WhenAll(stations.Select(async s => { var availabilityJson = await _workstationAvailabilityBll.GetAvailabilityAsync(s.WorkstationId, now, now, login, ct).ConfigureAwait(false); var rows = JsonSerializer.Deserialize>(availabilityJson, JsonOpts) ?? []; return (s.WorkstationId, Outage: rows.FirstOrDefault(r => r.EndedOn == null)); })).ConfigureAwait(false); var outageByStation = outageResults.ToDictionary(x => x.WorkstationId, x => x.Outage); var line = new LineDigitalTwinDTO { ProductionLineId = productionLineId, LineName = lineName, ShiftDate = shiftDate, ShiftId = shiftId }; foreach (var s in stations.OrderBy(s => s.PositionNo)) { line.Stations.Add(new LineDigitalTwinStationDTO { WorkStationId = s.WorkstationId, WorkStationCode = s.WorkstationCode, WorkStationName = s.WorkstationName, PositionNo = s.PositionNo, Employees = employeesByStation.TryGetValue(s.WorkstationId, out var emps) ? emps : [], Live = liveByStation.TryGetValue(s.WorkstationId, out var live) ? live : null, CurrentOutage = outageByStation.TryGetValue(s.WorkstationId, out var outage) ? outage : null }); } return JsonSerializer.Serialize(line); } // Plant Overview — one summary card's worth of data per tenant production line. public async Task GetPlantOverview(DateOnly shiftDate, int shiftId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("plant-overview", new { shiftDate, shiftId }); var lines = await _productionLineDal.GetLinesForTenant(login, ct).ConfigureAwait(false); var result = new PlantOverviewDTO { ShiftDate = shiftDate, ShiftId = shiftId }; foreach (var line in lines) { var stationsJson = await _lineStationDal.GetCurrentByLineAsync(line.LineId, login, ct).ConfigureAwait(false); var stations = JsonSerializer.Deserialize>(stationsJson, JsonOpts) ?? []; var skillGap = await BuildSkillGapResultAsync(line.LineId, shiftDate, shiftId, login, ct).ConfigureAwait(false); var occupiedStations = skillGap.Employees .Where(e => e.WorkStationId is not null) .Select(e => e.WorkStationId!.Value) .Distinct() .Count(); result.Lines.Add(new PlantOverviewLineDTO { ProductionLineId = line.LineId, LineCode = line.LineCode, LineName = line.LineName, TotalStations = stations.Count, OccupiedStations = occupiedStations, IdleStations = Math.Max(0, stations.Count - occupiedStations), TotalEmployees = skillGap.Employees.Count, EmployeesWithGap = skillGap.Employees.Count(e => e.HasAnyGap) }); } return JsonSerializer.Serialize(result); } // Deliberately a separate, explicitly-triggered action rather than something run on every // GetSkillGapForRoster fetch — publishing on every screen view would spam Training Need // creation every time someone opens the Skill Gap screen. Log-enforcement gaps are skipped // entirely (never block execution, lowest signal) so only Warn/Block gaps ever reach TMS. public async Task RaiseTrainingNeedsForRosterGaps(int productionLineId, DateOnly rosterDate, int shiftId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("raise-training-needs-for-roster-gaps", new { productionLineId, rosterDate, shiftId }); var result = await BuildSkillGapResultAsync(productionLineId, rosterDate, shiftId, login, ct).ConfigureAwait(false); int published = 0, skippedLogEnforcement = 0; foreach (var employee in result.Employees) { foreach (var skill in employee.Skills) { if (skill.GapStatus == "Met") continue; if (skill.Enforcement == Enforcement.Log) { skippedLogEnforcement++; continue; } if (await PublishSkillGapDetectedAsync(employee, skill, productionLineId, rosterDate, shiftId, login, ct).ConfigureAwait(false)) published++; } } return JsonSerializer.Serialize(new { PublishedCount = published, SkippedLogEnforcementCount = skippedLogEnforcement, TotalEmployees = result.Employees.Count }); } // Non-fatal: TMS raising a Training Need is a downstream concern; a Dapr outage must never // fail the roster-gap screen action that triggered this. private async Task PublishSkillGapDetectedAsync( EmployeeSkillGapDTO employee, SkillGapDetailDTO skill, int productionLineId, DateOnly rosterDate, int shiftId, LoginDTO login, CancellationToken ct) { try { var payload = new { EmployeeId = employee.EmployeeId, EmployeeName = employee.EmployeeName, SkillId = skill.SkillId, SkillName = skill.SkillName, RequiredLevel = skill.RequiredLevel, CurrentLevel = skill.EmployeeLevel, Enforcement = (byte)skill.Enforcement, GapStatus = skill.GapStatus, WiId = employee.WiId, WiTitle = employee.WiTitle, ProductionLineId = productionLineId, RosterDate = rosterDate, ShiftId = shiftId, TenantId = login.ClientId, DatabaseName = login.DatabaseName, ClientId = login.ClientId, UserId = login.UserId }; await _daprClient.PublishEventAsync("pubsub", "wi.skillgap.detected", payload, ct).ConfigureAwait(false); return true; } catch (Exception ex) { GB5Trace.MarkFailed("wi-skillgap-detected-dapr-publish-failed", ex); _logger.LogWarning(ex, "Dapr publish wi.skillgap.detected failed for EmployeeId {EmployeeId}, SkillId {SkillId}", employee.EmployeeId, skill.SkillId); return false; } } private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); // Extracted from GetSkillGapForRoster (unchanged logic) so GetLineDigitalTwin/GetPlantOverview // can reuse the typed result directly instead of re-deriving it from a JSON string. private async Task BuildSkillGapResultAsync(int productionLineId, DateOnly rosterDate, int shiftId, LoginDTO login, CancellationToken ct) { var staffing = await GetRosterStaffing(productionLineId, rosterDate, shiftId, login, ct).ConfigureAwait(false); var plannedItems = await GetPlannedItems(productionLineId, rosterDate, shiftId, login, ct).ConfigureAwait(false); var result = new SkillGapResultDTO { ProductionLineId = productionLineId, RosterDate = rosterDate, ShiftId = shiftId }; foreach (var item in plannedItems) { var itemInfo = await _skillGapDal.GetItemModelGroup(item.ItemId, login, ct).ConfigureAwait(false); if (itemInfo is null) continue; // item master row missing for this tenant — nothing to resolve a WI against // WI resolution reuses Feature 1's fixed MWIASSIGNMENT dimension match with the // item's real ModelId/ItemGroupId — JobStart is used as a representative trigger // for "which WI applies to this job," not tied to any specific execution event. var matches = (await _assignmentDal.ResolveForWorkstation( item.WorkStationId ?? -1, (int)TriggerEvent.JobStart, login, ct, workCenterId: -1, routingId: -1, routingOperationId: -1, modelId: itemInfo.ModelId, itemGroupId: itemInfo.ItemGroupId).ConfigureAwait(false)).ToList(); if (matches.Count == 0) continue; // no WI resolves for this item on this dimension set — nothing to check var winner = matches.OrderByDescending(m => m.SpecificityScore).First(); var skillReqs = (await _executionDal.GetSkillRequirements(winner.WiId, login, ct).ConfigureAwait(false)).ToList(); if (skillReqs.Count == 0) continue; // resolved WI has no skill requirements — nothing to check var wi = await _masterDal.GetWiById(winner.WiId, login, ct).ConfigureAwait(false); foreach (var (employeeId, source) in await GetEmployeeSources(item, staffing, login, ct).ConfigureAwait(false)) { var employeeName = await _skillGapDal.GetEmployeeName(employeeId, login, ct).ConfigureAwait(false); var empLevels = await _executionDal.GetEmployeeSkillLevels(employeeId, login, ct).ConfigureAwait(false); var empLevelMap = empLevels.ToDictionary(e => e.SkillId, e => e.ProficiencyNo); var skills = skillReqs.Select(sr => { var hasLevel = empLevelMap.TryGetValue(sr.SkillId, out var level); var status = !hasLevel ? "NoProfile" : (level >= sr.MinLevelNo ? "Met" : "Gap"); return new SkillGapDetailDTO { SkillId = sr.SkillId, SkillName = sr.SkillName, RequiredLevel = sr.MinLevelNo, EmployeeLevel = hasLevel ? level : null, Enforcement = sr.Enforcement, GapStatus = status }; }).ToList(); result.Employees.Add(new EmployeeSkillGapDTO { EmployeeId = employeeId, EmployeeName = employeeName, WorkStationId = item.WorkStationId, StartMinutes = item.StartMinutes, EndMinutes = item.EndMinutes, ItemId = item.ItemId, ItemName = item.ItemName ?? itemInfo.ItemName, WiId = winner.WiId, WiTitle = wi?.WiTitle, ResolvedVia = item.ResolvedVia, EmployeeSource = source, HasAnyGap = skills.Any(s => s.GapStatus != "Met"), Skills = skills }); } } return result; } // Union of the lightweight allocation-plan path (3c) with real MM work orders (3d) whose // ItemId isn't already covered by an allocation row for this exact line/date/shift. private async Task> GetPlannedItems( int productionLineId, DateOnly rosterDate, int shiftId, LoginDTO login, CancellationToken ct) { var allocations = (await _allocationDal.GetByLineDateShift(productionLineId, rosterDate, shiftId, login, ct) .ConfigureAwait(false)).ToList(); var plannedItems = allocations.Select(a => new WiPlannedItemDTO { ItemId = a.ItemId, ItemName = a.ItemName, WorkStationId = a.WorkStationId, StartMinutes = a.StartMinutes, EndMinutes = a.EndMinutes, ResolvedVia = "Allocation" }).ToList(); var allocatedItemIds = allocations.Select(a => a.ItemId).ToHashSet(); var workCenterIds = (await _skillGapDal.GetWorkCentersForLine(productionLineId, rosterDate, login, ct) .ConfigureAwait(false)).ToList(); if (workCenterIds.Count == 0) return plannedItems; var workOrders = await _skillGapDal.GetActiveWorkOrdersForWorkCenters( workCenterIds, rosterDate.ToDateTime(TimeOnly.MinValue), login, ct).ConfigureAwait(false); foreach (var wo in workOrders) { if (allocatedItemIds.Contains(wo.ItemId)) continue; // already covered by an explicit allocation row — don't double-count var candidates = await _workOrderDal.ResolveWorkstationsForWorkOrder(wo, login, ct).ConfigureAwait(false); var first = candidates.FirstOrDefault(); var item = await _skillGapDal.GetItemModelGroup(wo.ItemId, login, ct).ConfigureAwait(false); plannedItems.Add(new WiPlannedItemDTO { ItemId = wo.ItemId, ItemName = item?.ItemName, WorkStationId = first?.WorkStationId, ResolvedVia = "WorkOrder", IndentDetailId = wo.IndentDetailId }); } return plannedItems; } // Employee union: WI's own roster/staffing (filtered to this item's workstation, or every // staffed employee when the allocation covers the whole line), unioned with MM's own // per-process TINDENTRESOURCE plan for WorkOrder-sourced items (a second, more direct // "who's actually planned" signal for MM-integrated clients). Deduplicated by EmployeeId — // Roster checked first, so a double-planned employee produces one row, not two. private async Task> GetEmployeeSources( WiPlannedItemDTO item, List staffing, LoginDTO login, CancellationToken ct) { var result = new List<(int EmployeeId, string Source)>(); var seen = new HashSet(); foreach (var s in staffing) { if (s.IsAbsent) continue; if (item.WorkStationId is not null && s.WorkstationId != item.WorkStationId) continue; if (seen.Add(s.EmployeeId)) result.Add((s.EmployeeId, "Roster")); } if (item.IndentDetailId is int indentDetailId) { var resourceEmployees = await _skillGapDal.GetResourceEmployeesForWorkOrder(indentDetailId, login, ct) .ConfigureAwait(false); foreach (var employeeId in resourceEmployees) if (seen.Add(employeeId)) result.Add((employeeId, "IndentResource")); } return result; } private async Task> GetRosterStaffing( int productionLineId, DateOnly rosterDate, int shiftId, LoginDTO login, CancellationToken ct) { var roster = await _lineRosterDal.GetRosterHeaderAsync(productionLineId, rosterDate, shiftId, (byte)RosterType.Actual, login, ct).ConfigureAwait(false) ?? await _lineRosterDal.GetRosterHeaderAsync(productionLineId, rosterDate, shiftId, (byte)RosterType.Planned, login, ct).ConfigureAwait(false); if (roster is null) return []; var json = await _lineRosterDal.GetStaffingByRosterAsync(roster.LineShiftRosterId, login, ct).ConfigureAwait(false); return JsonSerializer.Deserialize>(json) ?? []; } }