using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using AyaNova.Util; using AyaNova.Api.ControllerHelpers; using AyaNova.Models; using System; using System.Linq; using Newtonsoft.Json.Linq; using System.Collections.Generic; namespace AyaNova.Biz {/* Contract general notes / changes =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- Overview V7 contracts did this: Limit rates that can be selected to only those selected in contract and which are set to "Contract rate" in themselves or just offer those contract rates in addition to regular rates apply a discount to all parts selected for that contract in effect V8 contracts need to do this: Markup OR Discount option (mutually exclusive) Rates in addition to parts Tag selectable to apply discount or markup Can have multiple by tag but can have only one by non tag (all parts, all rates) wo applies discount / markup if part or rate is tagged if tag is chosen Keep in mind individual Units will be contractable as well Special notes field kind of like popup notes but it doesn't popup, instead displays on workorder statically somewhere as a reminder. perhaps a contract in effect section with a ui affordance to open for more details or perhaps not. I can imagine the first time people would need to read it but after the thousandth it's just taking up space. Response time duration control for notification purposes, overrides default response time globally set if a unit has one and customer has one the shortest one applies as this is an entire workorder issue User can make seperate workorders to work around this if it's an issue Contracts are never tied directly to an object they affect other than the Customer or Headoffice with a foreign key link workorders should never be reliant on the existance of a contract but rather just have a contract applied to them for example Contracts *are* tied to objects that use them like Client, HeadOffice, Unit Which contract to apply In order of least to most specific: Headoffice, Customer, Unit so Unit contract overrides any above contract Contract applied field objects with contract that affects then should seperately have a contract name field for contract applied this supports not directly linking the contract but rather seeing it as an object that is applied to another object, not linked to it. Can apply, unapply, delete contract and no affect on primary wo or other object TAGS in v7 contracts could be set to apply to all parts or none in v8 need finer control so: Contract needs selectable tags for any aspect to apply Discount / markup etc parts / rates So, in practice like in v7 you set a discount for all parts but then you can also on the same line restrict it to specific part tags and select tags right there If any items have tags then there can be no "all Items" item? Or do they contradict each other in some way i.e. a discount for all but a special discount if it's tagged this (could be zero percent so no change if tagged that way as a way to Exclude certain items) The server then applies the discount based on whether the parts are tagged or not if that's the case in it's bizactions then the picklist can be supplied the variant for contract id which pulls the contract, finds the tags and then populates the picklist with the extra tag search MULTIPLE discount / markup ITEMS Can have ONE default for all box Can have multiple tagged ones (tag is REQUIRED) Not two can have same tags in them, that's a biz rule / error Algorithm: Default item applied normally UNLESS tagged item which is more specific overrides it */ internal class ContractBiz : BizObject, IJobObject, ISearchAbleObject, IReportAbleObject, IExportAbleObject, INotifiableObject { internal ContractBiz(AyContext dbcontext, long currentUserId, long userTranslationId, AuthorizationRoles UserRoles) { ct = dbcontext; UserId = currentUserId; UserTranslationId = userTranslationId; CurrentUserRoles = UserRoles; BizType = AyaType.Contract; } internal static ContractBiz GetBiz(AyContext ct, Microsoft.AspNetCore.Http.HttpContext httpContext = null) { if (httpContext != null) return new ContractBiz(ct, UserIdFromContext.Id(httpContext.Items), UserTranslationIdFromContext.Id(httpContext.Items), UserRolesFromContext.Roles(httpContext.Items)); else return new ContractBiz(ct, 1, ServerBootConfig.AYANOVA_DEFAULT_TRANSLATION_ID, AuthorizationRoles.BizAdmin); } //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task ExistsAsync(long id) { return await ct.Contract.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task CreateAsync(Contract newObject) { await ValidateAsync(newObject, null); if (HasErrors) return null; else { newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); await ct.Contract.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, BizType, AyaEvent.Created), ct); await SearchIndexAsync(newObject, true); await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); await HandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } // //////////////////////////////////////////////////////////////////////////////////////////////// // //DUPLICATE // // // internal async Task DuplicateAsync(long id) // { // Contract dbObject = await GetAsync(id, false); // if (dbObject == null) // { // AddError(ApiErrorCode.NOT_FOUND, "id"); // return null; // } // Contract newObject = new Contract(); // CopyObject.Copy(dbObject, newObject, "Wiki"); // string newUniqueName = string.Empty; // bool NotUnique = true; // long l = 1; // do // { // newUniqueName = Util.StringUtil.UniqueNameBuilder(dbObject.Name, l++, 255); // NotUnique = await ct.Contract.AnyAsync(m => m.Name == newUniqueName); // } while (NotUnique); // newObject.Name = newUniqueName; // newObject.Id = 0; // newObject.Concurrency = 0; // await ct.Contract.AddAsync(newObject); // await ct.SaveChangesAsync(); // await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, BizType, AyaEvent.Created), ct); // await SearchIndexAsync(newObject, true); // await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); // await HandlePotentialNotificationEvent(AyaEvent.Created, newObject); // await PopulateVizFields(newObject); // return newObject; // } //////////////////////////////////////////////////////////////////////////////////////////////// //GET // internal async Task GetAsync(long id, bool populateDisplayFields, bool logTheGetEvent = true) { var ret = await ct.Contract .Include(z => z.ContractPartOverrideItems) .Include(z => z.ContractServiceRateOverrideItems) .Include(z => z.ContractTravelRateOverrideItems) .Include(z => z.ServiceRateItems) .Include(z => z.TravelRateItems) .AsNoTracking() .SingleOrDefaultAsync(m => m.Id == id); if (populateDisplayFields) await PopulateVizFields(ret); if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, BizType, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task PutAsync(Contract putObject) { Contract dbObject = await GetAsync(putObject.Id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } if (dbObject.Concurrency != putObject.Concurrency) { AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } putObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); putObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); await ValidateAsync(putObject, dbObject); if (HasErrors) return null; ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await ExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, BizType, AyaEvent.Modified), ct); await SearchIndexAsync(putObject, false); await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags); await HandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); await PopulateVizFields(putObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task DeleteAsync(long id) { using (var transaction = await ct.Database.BeginTransactionAsync()) { try { Contract dbObject = await GetAsync(id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND); return false; } await ValidateCanDeleteAsync(dbObject); if (HasErrors) return false; if (HasErrors) return false; ct.Contract.Remove(dbObject); await ct.SaveChangesAsync(); await EventLogProcessor.DeleteObjectLogAsync(UserId, BizType, dbObject.Id, dbObject.Name, ct); await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, BizType, ct); await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct); await transaction.CommitAsync(); await HandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } return true; } } //////////////////////////////////////////////////////////////////////////////////////////////// //SEARCH // private async Task SearchIndexAsync(Contract obj, bool isNew) { var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, BizType); DigestSearchText(obj, SearchParams); if (isNew) await Search.ProcessNewObjectKeywordsAsync(SearchParams); else await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); } public async Task GetSearchResultSummary(long id) { var obj = await GetAsync(id, false); var SearchParams = new Search.SearchIndexProcessObjectParameters(); DigestSearchText(obj, SearchParams); return SearchParams; } public void DigestSearchText(Contract obj, Search.SearchIndexProcessObjectParameters searchParams) { if (obj != null) searchParams.AddText(obj.Notes) .AddText(obj.Name) .AddText(obj.Wiki) .AddText(obj.Tags) .AddText(obj.AlertNotes) .AddCustomFields(obj.CustomFields); } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task ValidateAsync(Contract proposedObj, Contract currentObj) { bool isNew = currentObj == null; //Name required if (string.IsNullOrWhiteSpace(proposedObj.Name)) AddError(ApiErrorCode.VALIDATION_REQUIRED, "Name"); //If name is otherwise OK, check that name is unique if (!PropertyHasErrors("Name")) { //Use Any command is efficient way to check existance, it doesn't return the record, just a true or false if (await ct.Contract.AnyAsync(m => m.Name == proposedObj.Name && m.Id != proposedObj.Id)) { AddError(ApiErrorCode.VALIDATION_NOT_UNIQUE, "Name"); } } //VALIDATE TAGGED ITEMS //PARTS if (proposedObj.ContractPartOverrideItems.Count > 0) { //List allTags = new List(); for (int i = 0; i < proposedObj.ContractPartOverrideItems.Count; i++) { var item = proposedObj.ContractPartOverrideItems[i]; if (item.Tags.Count < 1) AddError(ApiErrorCode.VALIDATION_REQUIRED, $"ContractPartOverrideItems[{i}].Tags"); // else // { // //add to list, check for dupes // foreach (string s in item.Tags) // { // if (allTags.Contains(s)) // { // AddError(ApiErrorCode.VALIDATION_NOT_UNIQUE, $"ContractPartOverrideItems[{i}].Tags"); // break; // } // else // { // allTags.Add(s); // } // } // } if (item.OverridePct == 0) AddError(ApiErrorCode.VALIDATION_REQUIRED, $"ContractPartOverrideItems[{i}].OverridePct"); } } //SERVICE RATES if (proposedObj.ContractServiceRateOverrideItems.Count > 0) { // List allTags = new List(); //check for overlapping dupes //[NO DON"T CHECK FOR DUPES, OR AT LEAST NOT LIKE THIS, REMOVING FOR NOW] //Not sure what the dupe intent was but it should be entirely duped, not just one tag in common as with this //Check for missing tags for (int i = 0; i < proposedObj.ContractServiceRateOverrideItems.Count; i++) { var item = proposedObj.ContractServiceRateOverrideItems[i]; if (item.Tags.Count < 1) AddError(ApiErrorCode.VALIDATION_REQUIRED, $"ContractServiceRateOverrideItems[{i}].Tags"); // else // { // //add to list, check for dupes // foreach (string s in item.Tags) // { // if (allTags.Contains(s)) // { // AddError(ApiErrorCode.VALIDATION_NOT_UNIQUE, $"ContractServiceRateOverrideItems[{i}].Tags"); // break; // } // else // { // allTags.Add(s); // } // } // } if (item.OverridePct == 0) AddError(ApiErrorCode.VALIDATION_REQUIRED, $"ContractServiceRateOverrideItems[{i}].OverridePct"); } } //TRAVEL RATES if (proposedObj.ContractTravelRateOverrideItems.Count > 0) { for (int i = 0; i < proposedObj.ContractTravelRateOverrideItems.Count; i++) { var item = proposedObj.ContractTravelRateOverrideItems[i]; if (item.Tags.Count < 1) AddError(ApiErrorCode.VALIDATION_REQUIRED, $"ContractTravelRateOverrideItems[{i}].Tags"); // else // { // //add to list, check for dupes // foreach (string s in item.Tags) // { // if (allTags.Contains(s)) // { // AddError(ApiErrorCode.VALIDATION_NOT_UNIQUE, $"ContractTravelRateOverrideItems[{i}].Tags"); // break; // } // else // { // allTags.Add(s); // } // } // } if (item.OverridePct == 0) AddError(ApiErrorCode.VALIDATION_REQUIRED, $"ContractTravelRateOverrideItems[{i}].OverridePct"); } } //VALIDATE CONTRACT SERVICE AND TRAVEL RATE ITEMS //Limit to list requires some to be set if (proposedObj.ServiceRateItems.Count == 0) { if (proposedObj.ContractServiceRatesOnly) AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "ContractServiceRatesOnly"); } //No duplicate rates //just filter them out rather than setting an error proposedObj.ServiceRateItems = proposedObj.ServiceRateItems.GroupBy(x => x.ServiceRateId).Select(y => y.First()).ToList(); //Limit to list requires some to be set if (proposedObj.TravelRateItems.Count == 0) { if (proposedObj.ContractTravelRatesOnly) AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "ContractTravelRatesOnly"); } //No duplicate rates //just filter them out rather than setting an error proposedObj.TravelRateItems = proposedObj.TravelRateItems.GroupBy(x => x.TravelRateId).Select(y => y.First()).ToList(); //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(x => x.FormKey == AyaType.Contract.ToString()); if (FormCustomization != null) { //Yeppers, do the validation, there are two, the custom fields and the regular fields that might be set to required //validate users choices for required non custom fields RequiredFieldsValidator.Validate(this, FormCustomization, proposedObj); //validate custom fields CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields); } } private async Task ValidateCanDeleteAsync(Contract inObj) { //FOREIGN KEY CHECKS if (await ct.Customer.AnyAsync(m => m.ContractId == inObj.Id)) AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Customer")); if (await ct.HeadOffice.AnyAsync(m => m.ContractId == inObj.Id)) AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("HeadOffice")); if (await ct.Unit.AnyAsync(m => m.ContractId == inObj.Id)) AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Unit")); if (await ct.WorkOrder.AnyAsync(m => m.ContractId == inObj.Id)) AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrder")); } //////////////////////////////////////////////////////////////////////////////////////////////// //REPORTING // public async Task GetReportData(DataListSelectedRequest dataListSelectedRequest) { var idList = dataListSelectedRequest.SelectedRowIds; JArray ReportData = new JArray(); while (idList.Any()) { var batch = idList.Take(IReportAbleObject.REPORT_DATA_BATCH_SIZE); idList = idList.Skip(IReportAbleObject.REPORT_DATA_BATCH_SIZE).ToArray(); //query for this batch, comes back in db natural order unfortunately var batchResults = await ct.Contract.Include(z => z.ContractPartOverrideItems) .Include(z => z.ContractServiceRateOverrideItems) .Include(z => z.ContractTravelRateOverrideItems) .Include(z => z.ServiceRateItems) .Include(z => z.TravelRateItems) .AsNoTracking() .Where(z => batch.Contains(z.Id)) .ToArrayAsync(); //order the results back into original var orderedList = from id in batch join z in batchResults on id equals z.Id select z; //cache enum list var ContractOverrideTypeEnumList = await AyaNova.Api.Controllers.EnumListController.GetEnumList( StringUtil.TrimTypeName(typeof(ContractOverrideType).ToString()), UserTranslationId, CurrentUserRoles); //cache translations needed var PreTrans = await TranslationBiz.GetSubsetStaticAsync(new List { "TimeSpanDays", "TimeSpanHours", "TimeSpanMinutes", "TimeSpanSeconds" }, UserTranslationId); foreach (Contract w in orderedList) { await PopulateVizFields(w, ContractOverrideTypeEnumList, PreTrans); var jo = JObject.FromObject(w); if (!JsonUtil.JTokenIsNullOrEmpty(jo["CustomFields"])) jo["CustomFields"] = JObject.Parse((string)jo["CustomFields"]); ReportData.Add(jo); } } return ReportData; } //populate viz fields from provided object private async Task PopulateVizFields(Contract o, List contractOverrideTypeEnumList = null, Dictionary preTrans = null) { if (contractOverrideTypeEnumList == null) contractOverrideTypeEnumList = await AyaNova.Api.Controllers.EnumListController.GetEnumList( StringUtil.TrimTypeName(typeof(ContractOverrideType).ToString()), UserTranslationId, CurrentUserRoles); if (preTrans == null) preTrans = await TranslationBiz.GetSubsetStaticAsync( new List { "TimeSpanDays", "TimeSpanHours", "TimeSpanMinutes", "TimeSpanSeconds" }, UserTranslationId); if (o.ResponseTime == TimeSpan.Zero) o.ResponseTimeViz = string.Empty; else o.ResponseTimeViz = $"{(preTrans["TimeSpanDays"])}: {o.ResponseTime.Days}, {(preTrans["TimeSpanHours"])}: {o.ResponseTime.Hours}, {(preTrans["TimeSpanMinutes"])}: {o.ResponseTime.Minutes} "; o.PartsOverrideTypeViz = contractOverrideTypeEnumList.Where(x => x.Id == (long)o.PartsOverrideType).Select(x => x.Name).First(); o.TravelRatesOverrideTypeViz = contractOverrideTypeEnumList.Where(x => x.Id == (long)o.TravelRatesOverrideType).Select(x => x.Name).First(); o.ServiceRatesOverrideTypeViz = contractOverrideTypeEnumList.Where(x => x.Id == (long)o.ServiceRatesOverrideType).Select(x => x.Name).First(); foreach (var i in o.ContractPartOverrideItems) i.OverrideTypeViz = contractOverrideTypeEnumList.Where(x => x.Id == (long)i.OverrideType).Select(x => x.Name).First(); foreach (var i in o.ContractTravelRateOverrideItems) i.OverrideTypeViz = contractOverrideTypeEnumList.Where(x => x.Id == (long)i.OverrideType).Select(x => x.Name).First(); foreach (var i in o.ContractServiceRateOverrideItems) i.OverrideTypeViz = contractOverrideTypeEnumList.Where(x => x.Id == (long)i.OverrideType).Select(x => x.Name).First(); foreach (var i in o.ServiceRateItems) i.ServiceRateViz = await ct.ServiceRate.AsNoTracking().Where(x => x.Id == i.ServiceRateId).Select(x => x.Name).FirstOrDefaultAsync(); foreach (var i in o.TravelRateItems) i.TravelRateViz = await ct.TravelRate.AsNoTracking().Where(x => x.Id == i.TravelRateId).Select(x => x.Name).FirstOrDefaultAsync(); } //////////////////////////////////////////////////////////////////////////////////////////////// // IMPORT EXPORT // public async Task GetExportData(DataListSelectedRequest dataListSelectedRequest) { //for now just re-use the report data code //this may turn out to be the pattern for most biz object types but keeping it seperate allows for custom usage from time to time return await GetReportData(dataListSelectedRequest); } //////////////////////////////////////////////////////////////////////////////////////////////// //JOB / OPERATIONS // public async Task HandleJobAsync(OpsJob job) { //Hand off the particular job to the corresponding processing code //NOTE: If this code throws an exception the caller (JobsBiz::ProcessJobsAsync) will automatically set the job to failed and log the exeption so //basically any error condition during job processing should throw up an exception if it can't be handled switch (job.JobType) { case JobType.BatchCoreObjectOperation: await ProcessBatchJobAsync(job); break; default: throw new System.ArgumentOutOfRangeException($"CustomerBiz.HandleJob-> Invalid job type{job.JobType.ToString()}"); } } private async Task ProcessBatchJobAsync(OpsJob job) { await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Running); await JobsBiz.LogJobAsync(job.GId, $"LT:StartJob {job.SubType}"); List idList = new List(); long FailedObjectCount = 0; JObject jobData = JObject.Parse(job.JobInfo); if (jobData.ContainsKey("idList")) idList = ((JArray)jobData["idList"]).ToObject>(); else idList = await ct.Contract.AsNoTracking().Select(z => z.Id).ToListAsync(); bool SaveIt = false; foreach (long id in idList) { try { SaveIt = false; ClearErrors(); Contract o = null; //save a fetch if it's a delete if (job.SubType != JobSubType.Delete) o = await GetAsync(id, false); switch (job.SubType) { case JobSubType.TagAddAny: case JobSubType.TagAdd: case JobSubType.TagRemoveAny: case JobSubType.TagRemove: case JobSubType.TagReplaceAny: case JobSubType.TagReplace: SaveIt = TagBiz.ProcessBatchTagOperation(o.Tags, (string)jobData["tag"], jobData.ContainsKey("toTag") ? (string)jobData["toTag"] : null, job.SubType); break; case JobSubType.Delete: if (!await DeleteAsync(id)) { await JobsBiz.LogJobAsync(job.GId, $"LT:Errors {GetErrorsAsString()} id {id}"); FailedObjectCount++; } break; default: throw new System.ArgumentOutOfRangeException($"ProcessBatchJobAsync -> Invalid job Subtype{job.SubType}"); } if (SaveIt) { o = await PutAsync(o); if (o == null) { await JobsBiz.LogJobAsync(job.GId, $"LT:Errors {GetErrorsAsString()} id {id}"); FailedObjectCount++; } } } catch (Exception ex) { await JobsBiz.LogJobAsync(job.GId, $"LT:Errors id({id})"); await JobsBiz.LogJobAsync(job.GId, ExceptionUtil.ExtractAllExceptionMessages(ex)); } } await JobsBiz.LogJobAsync(job.GId, $"LT:BatchJob {job.SubType} {idList.Count}{(FailedObjectCount > 0 ? " - LT:Failed " + FailedObjectCount : "")}"); await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Completed); } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task HandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{this.BizType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; //STANDARD EVENTS FOR ALL OBJECTS await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); //SPECIFIC EVENTS FOR THIS OBJECT }//end of process notifications ///////////////////////////////////////////////////////////////////// }//eoc }//eons