/// <summary> /// Executes the specified workflow. /// </summary> /// <param name="rockContext">The rock context.</param> /// <param name="action">The workflow action.</param> /// <param name="entity">The entity.</param> /// <param name="errorMessages">The error messages.</param> /// <returns></returns> /// <exception cref="System.NotImplementedException"></exception> public override bool Execute( RockContext rockContext, Rock.Model.WorkflowAction action, Object entity, out List<string> errorMessages ) { var checkInState = GetCheckInState( entity, out errorMessages ); if ( checkInState == null ) { return false; } var roomBalanceGroupTypes = GetAttributeValue( action, "RoomBalanceGrouptypes" ).SplitDelimitedValues().AsGuidList(); bool useGroupMembership = GetAttributeValue( action, "PrioritizeGroupMembership" ).AsBoolean(); int roomBalanceOverride = GetAttributeValue( action, "BalancingOverride" ).AsIntegerOrNull() ?? 5; var excludedLocations = GetAttributeValue( action, "ExcludedLocations" ).SplitDelimitedValues( false ) .Select( s => s.Trim() ); // get admin-selected attribute keys instead of using a hardcoded key var personSpecialNeedsKey = string.Empty; var personSpecialNeedsGuid = GetAttributeValue( action, "PersonSpecialNeedsAttribute" ).AsGuid(); if ( personSpecialNeedsGuid != Guid.Empty ) { personSpecialNeedsKey = AttributeCache.Read( personSpecialNeedsGuid, rockContext ).Key; } var groupSpecialNeedsKey = string.Empty; var groupSpecialNeedsGuid = GetAttributeValue( action, "GroupSpecialNeedsAttribute" ).AsGuid(); if ( personSpecialNeedsGuid != Guid.Empty ) { groupSpecialNeedsKey = AttributeCache.Read( groupSpecialNeedsGuid, rockContext ).Key; } var groupAgeRangeKey = string.Empty; var groupAgeRangeGuid = GetAttributeValue( action, "GroupAgeRangeAttribute" ).AsGuid(); if ( personSpecialNeedsGuid != Guid.Empty ) { groupAgeRangeKey = AttributeCache.Read( groupAgeRangeGuid, rockContext ).Key; } var groupGradeRangeKey = string.Empty; var groupGradeRangeGuid = GetAttributeValue( action, "GroupGradeRangeAttribute" ).AsGuid(); if ( personSpecialNeedsGuid != Guid.Empty ) { groupGradeRangeKey = AttributeCache.Read( groupGradeRangeGuid, rockContext ).Key; } // log a warning if any of the attributes are missing or invalid if ( string.IsNullOrWhiteSpace( personSpecialNeedsKey ) ) { action.AddLogEntry( string.Format( "The Person Special Needs attribute is not selected or invalid for '{0}'.", action.ActionType.Name ) ); } if ( string.IsNullOrWhiteSpace( groupSpecialNeedsKey ) ) { action.AddLogEntry( string.Format( "The Group Special Needs attribute is not selected or invalid for '{0}'.", action.ActionType.Name ) ); } if ( string.IsNullOrWhiteSpace( groupAgeRangeKey ) ) { action.AddLogEntry( string.Format( "The Group Age Range attribute is not selected or invalid for '{0}'.", action.ActionType.Name ) ); } if ( string.IsNullOrWhiteSpace( groupGradeRangeKey ) ) { action.AddLogEntry( string.Format( "The Group Grade Range attribute is not selected or invalid for '{0}'.", action.ActionType.Name ) ); } var family = checkInState.CheckIn.Families.FirstOrDefault( f => f.Selected ); if ( family != null ) { // don't process people who already have assignments foreach ( var person in family.People.Where( f => f.Selected && !f.GroupTypes.Any( gt => gt.Selected ) ) ) { decimal baseVariance = 100; char[] delimiter = { ',' }; // check if this person has special needs var hasSpecialNeeds = person.Person.GetAttributeValue( personSpecialNeedsKey ).AsBoolean(); // get a list of room balanced grouptype ID's since CheckInGroup model is a shallow clone var roomBalanceGroupTypeIds = person.GroupTypes.Where( gt => roomBalanceGroupTypes.Contains( gt.GroupType.Guid ) ) .Select( gt => gt.GroupType.Id ).ToList(); if ( person.GroupTypes.Count > 0 ) { CheckInGroupType bestGroupType = null; IEnumerable<CheckInGroup> validGroups; if ( person.GroupTypes.Count == 1 ) { bestGroupType = person.GroupTypes.FirstOrDefault(); validGroups = bestGroupType.Groups; } else { // Start with unfiltered groups since one criteria may not match exactly ( SN > Grade > Age ) validGroups = person.GroupTypes.SelectMany( gt => gt.Groups ); } // check how many groups exist without getting the whole list int numValidGroups = validGroups.Take( 2 ).Count(); if ( numValidGroups > 0 ) { CheckInGroup bestGroup = null; IEnumerable<CheckInLocation> validLocations; if ( numValidGroups == 1 ) { bestGroup = validGroups.FirstOrDefault(); validLocations = bestGroup.Locations; } else { // Select by group assignment first if ( useGroupMembership ) { var personAssignments = new GroupMemberService( rockContext ).GetByPersonId( person.Person.Id ) .Select( gm => gm.Group.Id ).ToList(); if ( personAssignments.Count > 0 ) { bestGroup = validGroups.FirstOrDefault( g => personAssignments.Contains( g.Group.Id ) ); } } // Select group by best fit if ( bestGroup == null ) { // Check age and special needs CheckInGroup closestAgeGroup = null; CheckInGroup closestNeedsGroup = null; var ageGroups = validGroups.Where( g => g.Group.AttributeValues.ContainsKey( groupAgeRangeKey ) && g.Group.AttributeValues[groupAgeRangeKey].Value != null && g.Group.AttributeValues.ContainsKey( personSpecialNeedsKey ) == hasSpecialNeeds ) .ToList() .Select( g => new { Group = g, AgeRange = g.Group.AttributeValues[groupAgeRangeKey].Value .Split( delimiter, StringSplitOptions.None ) .Where( av => !string.IsNullOrEmpty( av ) ) .Select( av => av.AsType<decimal>() ) } ) .ToList(); if ( ageGroups.Count > 0 ) { if ( person.Person.Age != null ) { baseVariance = 100; decimal personAge = (decimal)person.Person.AgePrecise; foreach ( var ageGroup in ageGroups.Where( g => g.AgeRange.Any() ) ) { var minAge = ageGroup.AgeRange.First(); var maxAge = ageGroup.AgeRange.Last(); var ageVariance = maxAge - minAge; if ( maxAge >= personAge && minAge <= personAge && ageVariance < baseVariance ) { closestAgeGroup = ageGroup.Group; baseVariance = ageVariance; if ( hasSpecialNeeds ) { closestNeedsGroup = closestAgeGroup; } } } } else if ( hasSpecialNeeds ) { // person has special needs but not an age, assign to first special needs group closestNeedsGroup = ageGroups.FirstOrDefault().Group; } } // Check grade CheckInGroup closestGradeGroup = null; if ( person.Person.GradeOffset != null ) { var gradeValues = DefinedTypeCache.Read( new Guid( Rock.SystemGuid.DefinedType.SCHOOL_GRADES ) ).DefinedValues; var gradeGroups = validGroups.Where( g => g.Group.AttributeValues.ContainsKey( groupGradeRangeKey ) && g.Group.AttributeValues[groupGradeRangeKey].Value != null ) .ToList() .Select( g => new { Group = g, GradeOffsets = g.Group.AttributeValues[groupGradeRangeKey].Value .Split( delimiter, StringSplitOptions.None ) .Where( av => !string.IsNullOrEmpty( av ) ) .Select( av => gradeValues.FirstOrDefault( v => v.Guid == new Guid( av ) ) ) .Select( av => av.Value.AsDecimal() ) } ) .ToList(); // Only check groups that have valid grade offsets if ( person.Person.GradeOffset != null && gradeGroups.Count > 0 ) { baseVariance = 100; decimal gradeOffset = (decimal)person.Person.GradeOffset.Value; foreach ( var gradeGroup in gradeGroups.Where( g => g.GradeOffsets.Any() ) ) { var minGradeOffset = gradeGroup.GradeOffsets.First(); var maxGradeOffset = gradeGroup.GradeOffsets.Last(); var gradeVariance = minGradeOffset - maxGradeOffset; if ( minGradeOffset >= gradeOffset && maxGradeOffset <= gradeOffset && gradeVariance < baseVariance ) { closestGradeGroup = gradeGroup.Group; baseVariance = gradeVariance; } } /* ======================================================== * optional scenario: find the next closest grade group * ========================================================= * if (grade > max) grade - max else if (grade < min) min - grade else 0; // add a tiny variance to offset larger groups: result += ((max - min)/100) * ========================================================= */ } } // Assignment priority: Group Membership, then Ability, then Grade, then Age, then the first non-excluded group // NOTE: if group member is prioritized (and membership exists) this section is skipped entirely bestGroup = closestNeedsGroup ?? closestGradeGroup ?? closestAgeGroup ?? validGroups.FirstOrDefault( g => !g.ExcludedByFilter ); // room balance if they fit into multiple groups if ( bestGroup != null && roomBalanceGroupTypeIds.Contains( bestGroup.Group.GroupTypeId ) ) { int? bestScheduleId = null; var availableSchedules = validGroups.SelectMany( g => g.Locations.SelectMany( l => l.Schedules ) ).ToList(); if ( availableSchedules.Any() ) { bestScheduleId = availableSchedules.OrderBy( s => s.StartTime ).Select( s => s.Schedule.Id ).FirstOrDefault(); } if ( bestScheduleId != null ) { validGroups = validGroups.Where( g => g.AvailableForSchedule.Contains( (int)bestScheduleId ) ); } var currentGroupAttendance = bestGroup.Locations.Select( l => Helpers.ReadAttendanceBySchedule( l.Location.Id, bestScheduleId ) ).Sum(); var lowestGroup = validGroups.Where( g => !g.ExcludedByFilter && !excludedLocations.Contains( g.Group.Name ) ) .Select( g => new { Group = g, Attendance = g.Locations.Select( l => Helpers.ReadAttendanceBySchedule( l.Location.Id, bestScheduleId ) ).Sum() } ) .OrderBy( g => g.Attendance ) .FirstOrDefault(); if ( lowestGroup != null && lowestGroup.Attendance < ( currentGroupAttendance - roomBalanceOverride ) ) { bestGroup = lowestGroup.Group; } } } validLocations = bestGroup.Locations; } // check how many locations exist without getting the whole list int numValidLocations = validLocations.Take( 2 ).Count(); if ( numValidLocations > 0 ) { CheckInLocation bestLocation = null; IEnumerable<CheckInSchedule> validSchedules; if ( numValidLocations == 1 ) { bestLocation = validLocations.FirstOrDefault(); validSchedules = bestLocation.Schedules; } else { var filteredLocations = validLocations.Where( l => !l.ExcludedByFilter && !excludedLocations.Contains( l.Location.Name ) && l.Schedules.Any( s => s.Schedule.IsCheckInActive ) ); // room balance if they fit into multiple locations if ( roomBalanceGroupTypeIds.Contains( bestGroup.Group.GroupTypeId ) ) { int? bestScheduleId = null; var availableSchedules = filteredLocations.SelectMany( l => l.Schedules ) .DistinctBy( s => s.Schedule.Id ).ToList(); if ( availableSchedules.Any() ) { bestScheduleId = availableSchedules.OrderBy( s => s.StartTime ).Select( s => s.Schedule.Id ).FirstOrDefault(); } if ( bestScheduleId != null ) { filteredLocations = filteredLocations.Where( l => l.AvailableForSchedule.Contains( (int)bestScheduleId ) ); } filteredLocations = filteredLocations.OrderBy( l => Helpers.ReadAttendanceBySchedule( l.Location.Id, bestScheduleId ) ); } bestLocation = filteredLocations.FirstOrDefault(); validSchedules = bestLocation.Schedules; } // check how many schedules exist without getting the whole list int numValidSchedules = validSchedules.Take( 2 ).Count(); if ( numValidSchedules > 0 ) { // finished finding assignment, verify everything is selected var bestSchedule = validSchedules.OrderBy( s => s.Schedule.StartTimeOfDay ).FirstOrDefault(); bestSchedule.Selected = true; bestSchedule.PreSelected = true; if ( bestLocation != null ) { bestLocation.PreSelected = true; bestLocation.Selected = true; if ( bestGroup != null ) { bestGroup.PreSelected = true; bestGroup.Selected = true; bestGroupType = person.GroupTypes.FirstOrDefault( gt => gt.GroupType.Id == bestGroup.Group.GroupTypeId ); if ( bestGroupType != null ) { bestGroupType.Selected = true; bestGroupType.PreSelected = true; person.Selected = true; person.PreSelected = true; } } } } } } } } } return true; }
/// <summary> /// Executes the specified workflow. /// </summary> /// <param name="rockContext">The rock context.</param> /// <param name="action">The workflow action.</param> /// <param name="entity">The entity.</param> /// <param name="errorMessages">The error messages.</param> /// <returns></returns> /// <exception cref="System.NotImplementedException"></exception> public override bool Execute( RockContext rockContext, Rock.Model.WorkflowAction action, Object entity, out List<string> errorMessages ) { var checkInState = GetCheckInState( entity, out errorMessages ); if ( checkInState == null ) { return false; } int selectFromDaysBack = checkInState.CheckInType.AutoSelectDaysBack; var roomBalanceGroupTypes = GetAttributeValue( action, "RoomBalanceGrouptypes" ).SplitDelimitedValues().AsGuidList(); int roomBalanceOverride = GetAttributeValue( action, "BalancingOverride" ).AsIntegerOrNull() ?? 5; int maxAssignments = GetAttributeValue( action, "MaxAssignments" ).AsIntegerOrNull() ?? 5; var excludedLocations = GetAttributeValue( action, "ExcludedLocations" ).SplitDelimitedValues( whitespace: false ) .Select( s => s.Trim() ); var personSpecialNeedsKey = string.Empty; var personSpecialNeedsGuid = GetAttributeValue( action, "PersonSpecialNeedsAttribute" ).AsGuid(); if ( personSpecialNeedsGuid != Guid.Empty ) { personSpecialNeedsKey = AttributeCache.Read( personSpecialNeedsGuid, rockContext ).Key; } // log a warning if the attribute is missing or invalid if ( string.IsNullOrWhiteSpace( personSpecialNeedsKey ) ) { action.AddLogEntry( string.Format( "The Person Special Needs attribute is not selected or invalid for '{0}'.", action.ActionType.Name ) ); } var family = checkInState.CheckIn.Families.FirstOrDefault( f => f.Selected ); if ( family != null ) { var cutoffDate = RockDateTime.Today.AddDays( selectFromDaysBack * -1 ); var attendanceService = new AttendanceService( rockContext ); // only process people who have been here before foreach ( var previousAttender in family.People.Where( p => p.Selected && !p.FirstTime ) ) { // get a list of this person's available grouptypes var availableGroupTypeIds = previousAttender.GroupTypes.Select( gt => gt.GroupType.Id ).ToList(); // order by most recent attendance var lastDateAttendances = attendanceService.Queryable().Where( a => a.PersonAlias.PersonId == previousAttender.Person.Id && availableGroupTypeIds.Contains( a.Group.GroupTypeId ) && a.StartDateTime >= cutoffDate && a.DidAttend == true ) .OrderByDescending( a => a.StartDateTime ).Take( maxAssignments ) .ToList(); if ( lastDateAttendances.Any() ) { var assignmentsGiven = 0; // get the most recent day, then start with the earliest attendances var lastAttended = lastDateAttendances.Max( a => a.StartDateTime ).Date; var numAttendances = lastDateAttendances.Count( a => a.StartDateTime >= lastAttended ); foreach ( var groupAttendance in lastDateAttendances.Where( a => a.StartDateTime >= lastAttended ).OrderBy( a => a.Schedule.StartTimeOfDay ) ) { bool currentlyCheckedIn = false; var serviceCutoff = groupAttendance.StartDateTime; if ( serviceCutoff > RockDateTime.Now.Date ) { // calculate the service window to determine if people are still checked in var serviceTime = groupAttendance.StartDateTime.Date + groupAttendance.Schedule.NextStartDateTime.Value.TimeOfDay; var serviceStart = serviceTime.AddMinutes( ( groupAttendance.Schedule.CheckInStartOffsetMinutes ?? 0 ) * -1.0 ); serviceCutoff = serviceTime.AddMinutes( ( groupAttendance.Schedule.CheckInEndOffsetMinutes ?? 0 ) ); currentlyCheckedIn = RockDateTime.Now > serviceStart && RockDateTime.Now < serviceCutoff; } // override exists in case they are currently checked in or have special needs bool useCheckinOverride = currentlyCheckedIn || previousAttender.Person.GetAttributeValue( personSpecialNeedsKey ).AsBoolean(); // get a list of room balanced grouptype ID's since CheckInGroup model is a shallow clone var roomBalanceGroupTypeIds = previousAttender.GroupTypes.Where( gt => roomBalanceGroupTypes.Contains( gt.GroupType.Guid ) ) .Select( gt => gt.GroupType.Id ).ToList(); // Start with filtered groups unless they have abnormal age and grade parameters (1%) var groupType = previousAttender.GroupTypes.FirstOrDefault( gt => gt.GroupType.Id == groupAttendance.Group.GroupTypeId && ( !gt.ExcludedByFilter || useCheckinOverride ) ); if ( groupType != null ) { // get available schedules and order earliest first var availableSchedules = groupType.Groups.SelectMany( g => g.Locations.Where( l => l.IsActiveAndNotFull ).SelectMany( l => l.Schedules ) ) .DistinctBy( s => s.Schedule.Id ).OrderBy( s => s.Schedule.StartTimeOfDay ).ToList(); // select the schedule first so group/location attendance can be filtered by schedule CheckInSchedule schedule = null; if ( availableSchedules.Count == 1 ) { schedule = availableSchedules.FirstOrDefault( s => !s.ExcludedByFilter || useCheckinOverride ); } else { // only use for current check-ins, otherwise SN might get the wrong schedule if ( currentlyCheckedIn ) { schedule = availableSchedules.FirstOrDefault( s => s.Schedule.Id == groupAttendance.ScheduleId && ( !s.ExcludedByFilter || useCheckinOverride ) ); } // skip to the next service when assigning multiple services schedule = schedule ?? availableSchedules.Skip( assignmentsGiven ).FirstOrDefault( s => !s.ExcludedByFilter ); } CheckInGroup group = null; if ( groupType.Groups.Count == 1 ) { // Only a single group is open group = groupType.Groups.FirstOrDefault( g => !g.ExcludedByFilter || useCheckinOverride ); } else { // Pick the group they last attended, as long as it's open or what they're currently checked into group = groupType.Groups.FirstOrDefault( g => g.Group.Id == groupAttendance.GroupId && ( !g.ExcludedByFilter || useCheckinOverride ) ); // Room balance only on new check-ins if ( group != null && roomBalanceGroupTypeIds.Contains( group.Group.GroupTypeId ) && !excludedLocations.Contains( group.Group.Name ) && !useCheckinOverride ) { // Make sure balanced rooms are open for the current service var currentAttendance = group.Locations.Where( l => l.AvailableForSchedule.Contains( schedule.Schedule.Id ) ) .Select( l => Helpers.ReadAttendanceBySchedule( l.Location.Id, schedule.Schedule.Id ) ).Sum(); var lowestAttendedGroup = groupType.Groups.Where( g => g.AvailableForSchedule.Contains( schedule.Schedule.Id ) ) .Where( g => !g.ExcludedByFilter && !excludedLocations.Contains( g.Group.Name ) ) .Select( g => new { Group = g, Attendance = g.Locations.Select( l => Helpers.ReadAttendanceBySchedule( l.Location.Id, schedule.Schedule.Id ) ).Sum() } ) .OrderBy( g => g.Attendance ) .FirstOrDefault(); if ( lowestAttendedGroup != null && lowestAttendedGroup.Attendance < ( currentAttendance - roomBalanceOverride + 1 ) ) { group = lowestAttendedGroup.Group; } } } if ( group != null ) { CheckInLocation location = null; if ( group.Locations.Count == 1 ) { // Only a single location is open location = group.Locations.FirstOrDefault( l => !l.ExcludedByFilter || useCheckinOverride ); } else { // Pick the location they last attended, as long as it's open or what they're currently checked into location = group.Locations.FirstOrDefault( l => l.Location.Id == groupAttendance.LocationId && ( !l.ExcludedByFilter || useCheckinOverride ) ); // Room balance only on new check-ins if ( location != null && roomBalanceGroupTypeIds.Contains( group.Group.GroupTypeId ) && !excludedLocations.Contains( location.Location.Name ) && !useCheckinOverride ) { var currentAttendance = Helpers.ReadAttendanceBySchedule( location.Location.Id, schedule.Schedule.Id ); var lowestAttendedLocation = group.Locations.Where( l => l.AvailableForSchedule.Contains( schedule.Schedule.Id ) ) .Where( l => !l.ExcludedByFilter && !excludedLocations.Contains( l.Location.Name ) ) .Select( l => new { Location = l, Attendance = Helpers.ReadAttendanceBySchedule( l.Location.Id, schedule.Schedule.Id ) } ) .OrderBy( l => l.Attendance ) .FirstOrDefault(); if ( lowestAttendedLocation != null && lowestAttendedLocation.Attendance < ( currentAttendance - roomBalanceOverride + 1 ) ) { location = lowestAttendedLocation.Location; } } } if ( location != null && schedule != null ) { // make sure the selected schedule is the one owned by this location context schedule = location.Schedules.FirstOrDefault( s => s.Schedule.Id == schedule.Schedule.Id ); // it's impossible to currently be checked in unless these match exactly if ( group.Group.Id == groupAttendance.GroupId && location.Location.Id == groupAttendance.LocationId && schedule.Schedule.Id == groupAttendance.ScheduleId ) { // Checkout feature either removes the attendance or sets the EndDateTime var endOfCheckinWindow = groupAttendance.EndDateTime ?? serviceCutoff; schedule.LastCheckIn = endOfCheckinWindow; location.LastCheckIn = endOfCheckinWindow; group.LastCheckIn = endOfCheckinWindow; groupType.LastCheckIn = endOfCheckinWindow; previousAttender.LastCheckIn = endOfCheckinWindow; } // finished finding assignment, verify everything is selected schedule.Selected = true; schedule.PreSelected = true; location.Selected = true; location.PreSelected = true; group.Selected = true; group.PreSelected = true; groupType.Selected = true; groupType.PreSelected = true; previousAttender.Selected = true; previousAttender.PreSelected = true; assignmentsGiven++; } } } } } } } return true; }