using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.EntityFrameworkCore.Storage; using AyaNova.Util; using AyaNova.Api.ControllerHelpers; using AyaNova.Models; using System.Linq; using System; using Newtonsoft.Json.Linq; using System.Collections.Generic; using EnumsNET; namespace AyaNova.Biz { internal class QuoteBiz : BizObject, IJobObject, ISearchAbleObject, IReportAbleObject, IExportAbleObject { internal QuoteBiz(AyContext dbcontext, long currentUserId, long userTranslationId, AuthorizationRoles UserRoles, UserType currentUserType) { ct = dbcontext; UserId = currentUserId; UserTranslationId = userTranslationId; CurrentUserRoles = UserRoles; BizType = AyaType.Quote; CurrentUserType = currentUserType; //Sub-role rights flags UserIsTechRestricted = CurrentUserRoles.HasFlag(AuthorizationRoles.TechRestricted); UserIsSubContractorFull = CurrentUserType == UserType.ServiceContractor && CurrentUserRoles.HasFlag(AuthorizationRoles.SubContractor); UserIsSubContractorRestricted = CurrentUserType == UserType.ServiceContractor && CurrentUserRoles.HasFlag(AuthorizationRoles.SubContractorRestricted); UserIsRestrictedType = UserIsTechRestricted || UserIsSubContractorFull || UserIsSubContractorRestricted; UserCanViewPartCosts = CurrentUserRoles.HasFlag(AuthorizationRoles.InventoryRestricted) || CurrentUserRoles.HasFlag(AuthorizationRoles.Inventory) || CurrentUserRoles.HasFlag(AuthorizationRoles.BizAdmin) || CurrentUserRoles.HasFlag(AuthorizationRoles.Accounting); UserCanViewLaborOrTravelRateCosts = CurrentUserRoles.HasFlag(AuthorizationRoles.Service) || CurrentUserRoles.HasFlag(AuthorizationRoles.ServiceRestricted) || CurrentUserRoles.HasFlag(AuthorizationRoles.BizAdmin) || CurrentUserRoles.HasFlag(AuthorizationRoles.Accounting); UserCanViewLoanerCosts = CurrentUserRoles.HasFlag(AuthorizationRoles.Service) || CurrentUserRoles.HasFlag(AuthorizationRoles.ServiceRestricted) || CurrentUserRoles.HasFlag(AuthorizationRoles.BizAdmin) || CurrentUserRoles.HasFlag(AuthorizationRoles.Accounting); } internal static QuoteBiz GetBiz(AyContext ct, Microsoft.AspNetCore.Http.HttpContext httpContext = null) { if (httpContext != null) return new QuoteBiz(ct, UserIdFromContext.Id(httpContext.Items), UserTranslationIdFromContext.Id(httpContext.Items), UserRolesFromContext.Roles(httpContext.Items), UserTypeFromContext.Type(httpContext.Items)); else return new QuoteBiz(ct, 1, ServerBootConfig.AYANOVA_DEFAULT_TRANSLATION_ID, AuthorizationRoles.BizAdmin, UserType.NotService);//picked not service arbitrarily, probably a non-factor } private VizCache vc = new VizCache(); private ObjectCache oc = new ObjectCache(); /* ██████╗ ██╗ ██╗ ██████╗ ████████╗███████╗ ██╔═══██╗██║ ██║██╔═══██╗╚══██╔══╝██╔════╝ ██║ ██║██║ ██║██║ ██║ ██║ █████╗ ██║▄▄ ██║██║ ██║██║ ██║ ██║ ██╔══╝ ╚██████╔╝╚██████╔╝╚██████╔╝ ██║ ███████╗ ╚══▀▀═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ */ #region Quote level //////////////////////////////////////////////////////////////////////////////////////////////// // SUBRIGHTS / RESTRICTIONS FOR WORK ORDER // //Note: these restrictions and rights are in addition to the basic fundamental role access rights (layer 1) //and are considered after role rights have already been consulted first (usually at the controller level) internal UserType CurrentUserType { get; set; } internal bool UserIsRestrictedType { get; set; } internal bool UserIsTechRestricted { get; set; } internal bool UserIsSubContractorFull { get; set; } internal bool UserIsSubContractorRestricted { get; set; } internal bool UserCanViewPartCosts { get; set; } internal bool UserCanViewLaborOrTravelRateCosts { get; set; } internal bool UserCanViewLoanerCosts { get; set; } //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task QuoteExistsAsync(long id) { return await ct.Quote.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task QuoteCreateAsync(Quote newObject, bool populateViz = true) { using (var transaction = await ct.Database.BeginTransactionAsync()) { await QuoteValidateAsync(newObject, null); if (HasErrors) return null; else { await QuoteBizActionsAsync(AyaEvent.Created, newObject, null, null); newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); await ct.Quote.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, BizType, AyaEvent.Created), ct); await QuoteSearchIndexAsync(newObject, true); await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); //# NOTE: only internal code can post an entire quote graph, no external user can as controller will reject right up front //however, internally seeder will post entire workorders if (newObject.Items.Count > 0) { await GetCurrentContractFromContractIdAsync(newObject.ContractId); //GRANDCHILD BIZ ACTIONS foreach (QuoteItem wi in newObject.Items) { foreach (QuoteItemPart wip in wi.Parts) await PartBizActionsAsync(AyaEvent.Created, wip, null, null); foreach (QuoteItemLoan wil in wi.Loans) await LoanBizActionsAsync(AyaEvent.Created, wil, null, null); } await ct.SaveChangesAsync(); //NOTE: not running individual notification here for children, seeder won't require it and that's all that posts an entire wo currently } await transaction.CommitAsync(); if (populateViz) await QuotePopulateVizFields(newObject, true, false); if (newObject.GenCopyAttachmentsFrom != null && !newObject.GenCopyAttachmentsFrom.IsEmpty) { //copy attachment from existing object await AttachmentBiz.DuplicateAttachments(newObject.GenCopyAttachmentsFrom, new AyaTypeId(AyaType.Quote, newObject.Id), this.UserId, ct); newObject.GenCopyAttachmentsFrom = null;//so it doesn't get returned } await QuoteHandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } } //quote needs to be fetched internally from several places for rule checking etc //this just gets it raw and lets others process private async Task QuoteGetFullAsync(long id) { //https://docs.microsoft.com/en-us/ef/core/querying/related-data //docs say this will not query twice but will recognize the duplicate woitem bit which is required for multiple grandchild collections return await ct.Quote.AsSplitQuery().AsNoTracking() .Include(s => s.States.OrderBy(item => item.Id)) .Include(w => w.Items.OrderBy(item => item.Sequence)) .ThenInclude(wi => wi.Expenses.OrderBy(z => z.Id)) .Include(w => w.Items) .ThenInclude(wi => wi.Labors.OrderBy(l => l.ServiceStartDate)) .Include(w => w.Items) .ThenInclude(wi => wi.Loans.OrderBy(z => z.OutDate)) .Include(w => w.Items) .ThenInclude(wi => wi.Parts.OrderBy(z => z.Id)) .Include(w => w.Items) .ThenInclude(wi => wi.ScheduledUsers.OrderBy(z => z.StartDate)) .Include(w => w.Items) .ThenInclude(wi => wi.Tasks.OrderBy(t => t.Sequence)) .Include(w => w.Items) .ThenInclude(wi => wi.Travels.OrderBy(z => z.TravelStartDate)) .Include(w => w.Items) .ThenInclude(wi => wi.Units.OrderBy(z => z.Id)) .Include(w => w.Items) .ThenInclude(wi => wi.OutsideServices.OrderBy(z => z.SentDate)) .SingleOrDefaultAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// // GET // internal async Task QuoteGetAsync(long id, bool populateDisplayFields, bool logTheGetEvent = true, bool populateForReporting = false) { var ret = await QuoteGetFullAsync(id); if (ret != null) { var stat = await GetCurrentQuoteStatusFromRelatedAsync(BizType, ret.Id); ret.IsLockedAtServer = stat.Locked; var userIsTechRestricted = UserIsTechRestricted; var userIsSubContractorFull = UserIsSubContractorFull; var userIsSubContractorRestricted = UserIsSubContractorRestricted; var userIsRestricted = (userIsTechRestricted || userIsSubContractorFull || userIsSubContractorRestricted); if (userIsRestricted) { //Restricted users can only work with quote items they are scheduled on List removeItems = new List(); //gather list of items to remove by checking if they are scheduled on them or not foreach (QuoteItem wi in ret.Items) { var userIsSelfScheduledOnThisItem = false; foreach (QuoteItemScheduledUser su in wi.ScheduledUsers) { if (su.UserId == UserId) { userIsSelfScheduledOnThisItem = true; break; } } if (!userIsSelfScheduledOnThisItem) removeItems.Add(wi); } foreach (var removeitem in removeItems) { ret.Items.Remove(removeitem); ret.IsCompleteRecord = false; } //Restricted users may have further restrictions foreach (QuoteItem wi in ret.Items) { //all restricted types wi.ScheduledUsers.RemoveAll(x => x.UserId != UserId); wi.Labors.RemoveAll(x => x.UserId != UserId); wi.Travels.RemoveAll(x => x.UserId != UserId); if (userIsTechRestricted) { wi.Expenses.RemoveAll(x => x.UserId != UserId); } if (userIsSubContractorFull) { wi.Expenses.RemoveAll(x => true); wi.OutsideServices.RemoveAll(x => true); } if (userIsSubContractorRestricted) { wi.Units.RemoveAll(x => true); wi.Parts.RemoveAll(x => true); wi.Expenses.RemoveAll(x => true); wi.Loans.RemoveAll(x => true); wi.OutsideServices.RemoveAll(x => true); } //tasks are allowed to be viewed and update the task completion types } } if (populateDisplayFields) await QuotePopulateVizFields(ret, false, populateForReporting); if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, BizType, AyaEvent.Retrieved), ct); } return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task QuotePutAsync(Quote putObject) { //## PUT HEADER ONLY, NO ALLOWANCE FOR PUT OF ENTIRE WORKORDER //Note: this is intentionally not using the getasync because //doing so would invoke the children which would then get deleted on save since putobject has no children Quote dbObject = await ct.Quote.AsNoTracking().FirstOrDefaultAsync(z => z.Id == putObject.Id); 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(putObject.Tags); putObject.CustomFields = JsonUtil.CompactJson(putObject.CustomFields); await QuoteValidateAsync(putObject, dbObject); if (HasErrors) return null; await QuoteBizActionsAsync(AyaEvent.Modified, putObject, dbObject, null); long? newContractId = null; if (putObject.ContractId != dbObject.ContractId)//manual change of contract { newContractId = putObject.ContractId; await GetCurrentContractFromContractIdAsync(newContractId); } ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await QuoteExistsAsync(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 QuoteSearchIndexAsync(putObject, false); await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags); await QuotePopulateVizFields(putObject, true, false); await QuoteHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task QuoteDeleteAsync(long id) { using (var transaction = await ct.Database.BeginTransactionAsync()) { Quote dbObject = await ct.Quote.AsNoTracking().Where(z => z.Id == id).FirstOrDefaultAsync();// QuoteGetAsync(id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND); return false; } await QuoteValidateCanDelete(dbObject); if (HasErrors) return false; { var IDList = await ct.Review.AsNoTracking().Where(x => x.AType == AyaType.Quote && x.ObjectId == id).Select(x => x.Id).ToListAsync(); if (IDList.Count() > 0) { ReviewBiz b = new ReviewBiz(ct, UserId, UserTranslationId, CurrentUserRoles); foreach (long ItemId in IDList) if (!await b.DeleteAsync(ItemId, transaction)) { AddError(ApiErrorCode.CHILD_OBJECT_ERROR, null, $"Review [{ItemId}]: {b.GetErrorsAsString()}"); return false; } } } //States collection if (!await StateDeleteAsync(id, transaction)) return false; //collect the child id's to delete var ItemIds = await ct.QuoteItem.AsNoTracking().Where(z => z.QuoteId == id).Select(z => z.Id).ToListAsync(); //Delete children foreach (long ItemId in ItemIds) if (!await ItemDeleteAsync(ItemId, transaction)) return false; ct.Quote.Remove(dbObject); await ct.SaveChangesAsync(); await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, dbObject.Serial.ToString(), ct); await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); await transaction.CommitAsync(); await QuoteHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); return true; } } //////////////////////////////////////////////////////////////////////////////////////////////// //BIZ ACTIONS // // private async Task QuoteBizActionsAsync(AyaEvent ayaEvent, Quote newObj, Quote oldObj, IDbContextTransaction transaction) { //automatic actions on record change, called AFTER validation and BEFORE save //so changes here will be saved by caller //currently no processing required except for created or modified at this time if (ayaEvent != AyaEvent.Created && ayaEvent != AyaEvent.Modified) return; //CREATED OR MODIFIED if (ayaEvent == AyaEvent.Created || ayaEvent == AyaEvent.Modified) { } //CREATION ACTIONS if (ayaEvent == AyaEvent.Created) { await AutoSetContractAsync(newObj); await AutoSetAddressAsync(newObj); } //MODIFIED ACTIONS if (ayaEvent == AyaEvent.Modified) { //if customer changed then contractId must be re-checked if (newObj.CustomerId != oldObj.CustomerId) { await AutoSetContractAsync(newObj); await AutoSetAddressAsync(newObj); } } } private async Task AutoSetAddressAsync(Quote newObj) { if (!string.IsNullOrWhiteSpace(newObj.PostAddress) || !string.IsNullOrWhiteSpace(newObj.Address)) return; if (newObj.CustomerId == 0) return; var cust = await ct.Customer.AsNoTracking().Where(z => z.Id == newObj.CustomerId).FirstOrDefaultAsync(); if (cust == null) return; newObj.PostAddress = cust.PostAddress; newObj.PostCity = cust.PostCity; newObj.PostRegion = cust.PostRegion; newObj.PostCountry = cust.PostCountry; newObj.PostCode = cust.PostCode; newObj.Address = cust.Address; newObj.City = cust.City; newObj.Region = cust.Region; newObj.Country = cust.Country; newObj.AddressPostal = cust.AddressPostal; newObj.Latitude = cust.Latitude; newObj.Longitude = cust.Longitude; if (cust.BillHeadOffice == true && cust.HeadOfficeId != null) { var head = await ct.HeadOffice.AsNoTracking().Where(z => z.Id == cust.HeadOfficeId).FirstOrDefaultAsync(); if (head == null) return; newObj.PostAddress = head.PostAddress; newObj.PostCity = head.PostCity; newObj.PostRegion = head.PostRegion; newObj.PostCountry = head.PostCountry; newObj.PostCode = head.PostCode; } } private async Task AutoSetContractAsync(Quote newObj) { //first reset contract fetched flag so a fresh copy is taken //in case it was set already by other operations mFetchedContractAlready = false; //CONTRACT AUTO SET //failsafe newObj.ContractId = null; if (newObj.CustomerId != 0) { //precedence: unit->customer->headoffice var cust = await ct.Customer.AsNoTracking().Where(z => z.Id == newObj.CustomerId).Select(z => new { headofficeId = z.HeadOfficeId, contractId = z.ContractId, contractExpires = z.ContractExpires }).FirstOrDefaultAsync(); //first set it to the customer one if available in case the ho one has expired then set the ho if applicable if (cust.contractId != null && cust.contractExpires > DateTime.UtcNow) newObj.ContractId = cust.contractId; else if (cust.headofficeId != null) { var head = await ct.HeadOffice.AsNoTracking().Where(z => z.Id == cust.headofficeId).Select(z => new { contractId = z.ContractId, contractExpires = z.ContractExpires }).FirstOrDefaultAsync(); if (head.contractId != null && head.contractExpires > DateTime.UtcNow) newObj.ContractId = head.contractId; } } } //////////////////////////////////////////////////////////////////////////////////////////////// //GET WORKORDER ID FROM DESCENDANT TYPE AND ID // internal static async Task GetQuoteIdFromRelativeAsync(AyaType ayaType, long id, AyContext ct) { ParentAndChildItemId w = new ParentAndChildItemId(); long itemid = 0; switch (ayaType) { case AyaType.Quote: w.ParentId = id; w.ChildItemId = 0; return w; case AyaType.QuoteItem: itemid = id; break; case AyaType.QuoteItemExpense: itemid = await ct.QuoteItemExpense.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); break; case AyaType.QuoteItemLabor: itemid = await ct.QuoteItemLabor.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); break; case AyaType.QuoteItemLoan: itemid = await ct.QuoteItemLoan.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); break; case AyaType.QuoteItemPart: itemid = await ct.QuoteItemPart.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); break; case AyaType.QuoteItemScheduledUser: itemid = await ct.QuoteItemScheduledUser.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); break; case AyaType.QuoteItemTask: itemid = await ct.QuoteItemTask.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); break; case AyaType.QuoteItemTravel: itemid = await ct.QuoteItemTravel.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); break; case AyaType.QuoteItemOutsideService: itemid = await ct.QuoteItemOutsideService.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); break; case AyaType.QuoteStatus: w.ParentId = await ct.QuoteState.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteId).SingleOrDefaultAsync(); w.ChildItemId = 0; return w; case AyaType.QuoteItemUnit: itemid = await ct.QuoteItemUnit.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); break; default: throw new System.NotSupportedException($"QuoteBiz::GetQuoteIdFromRelativeAsync -> AyaType {ayaType.ToString()} is not supported"); } w.ParentId = await ct.QuoteItem.AsNoTracking() .Where(z => z.Id == itemid) .Select(z => z.QuoteId) .SingleOrDefaultAsync(); w.ChildItemId = itemid; return w; } //////////////////////////////////////////////////////////////////////////////////////////////// //SEARCH // private async Task QuoteSearchIndexAsync(Quote 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, AyaType specificType) { switch (specificType) { case AyaType.Quote: var obj = await ct.Quote.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id);//# NOTE intentionally not calling quote get async here, don't need the whole graph var SearchParams = new Search.SearchIndexProcessObjectParameters(); DigestSearchText(obj, SearchParams); return SearchParams; case AyaType.QuoteItem: return await ItemGetSearchResultSummary(id); case AyaType.QuoteItemExpense: return await ExpenseGetSearchResultSummary(id); case AyaType.QuoteItemLabor: return await LaborGetSearchResultSummary(id); case AyaType.QuoteItemLoan: return await LoanGetSearchResultSummary(id); case AyaType.QuoteItemPart: return await PartGetSearchResultSummary(id); case AyaType.QuoteItemTask: return await TaskGetSearchResultSummary(id); case AyaType.QuoteItemTravel: return await TravelGetSearchResultSummary(id); case AyaType.QuoteItemOutsideService: return await OutsideServiceGetSearchResultSummary(id); case AyaType.QuoteItemUnit: return await UnitGetSearchResultSummary(id); default: return null; } } public void DigestSearchText(Quote obj, Search.SearchIndexProcessObjectParameters searchParams) { if (obj != null) searchParams.AddText(obj.Notes) .AddText(obj.Introduction) .AddText(obj.Serial) .AddText(obj.InternalReferenceNumber) .AddText(obj.CustomerReferenceNumber) .AddText(obj.CustomerContactName) .AddText(obj.PostAddress) .AddText(obj.PostCity) .AddText(obj.PostRegion) .AddText(obj.PostCountry) .AddText(obj.PostCode) .AddText(obj.Address) .AddText(obj.City) .AddText(obj.Region) .AddText(obj.Country) .AddText(obj.AddressPostal) .AddText(obj.Wiki) .AddText(obj.Tags) .AddCustomFields(obj.CustomFields); } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // //Can save or update? private async Task QuoteValidateAsync(Quote proposedObj, Quote currentObj) { //This may become necessary for v8migrate, leaving out for now //skip validation if seeding //if(ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; //run validation and biz rules bool isNew = currentObj == null; //Check restricted role preventing create if (isNew && UserIsRestrictedType) { AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return;//this is a completely disqualifying error } //Check state if updatable right now if (!isNew) { //Front end is coded to save the state first before any other updates if it has changed and it would not be //a part of this header update so it's safe to check it here as it will be most up to date var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); if (CurrentWoStatus.Locked) { AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); return;//this is a completely disqualifying error } } //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.Quote.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 QuoteValidateCanDelete(Quote dbObject) { //Check restricted role preventing create if (UserIsRestrictedType) { AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return;//this is a completely disqualifying error } if (await ct.WorkOrder.AnyAsync(m => m.FromQuoteId == dbObject.Id)) AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Quote")); } //////////////////////////////////////////////////////////////////////////////////////////////// // GET PARTIAL WORKORDER FOR REPORTING // (returns quote consisting only of the path from child or grandchild up to header populated // with display data for reporting) // internal async Task QuoteGetPartialAsync(AyaType ayaType, long id, bool includeWoItemDescendants, bool populateForReporting) { //if it's the entire quote just get, populate and return as normal if (ayaType == AyaType.Quote) return await QuoteGetAsync(id, true, false, populateForReporting); var wid = await GetQuoteIdFromRelativeAsync(ayaType, id, ct); //get header only var ret = await ct.Quote.AsNoTracking().Include(s => s.States).SingleOrDefaultAsync(x => x.Id == wid.ParentId); //not found don't bomb, just return null if (ret == null) return ret; //explicit load subitems as required... QuoteItem quoteitem = null; //it's requesting a fully populated woitem so do that here if (includeWoItemDescendants) { quoteitem = await ct.QuoteItem.AsSplitQuery() .AsNoTracking() .Include(wi => wi.Expenses) .Include(wi => wi.Labors) .Include(wi => wi.Loans) .Include(wi => wi.Parts) .Include(wi => wi.ScheduledUsers) .Include(wi => wi.Tasks) .Include(wi => wi.Travels) .Include(wi => wi.Units) .Include(wi => wi.OutsideServices) .SingleOrDefaultAsync(z => z.Id == wid.ChildItemId); } else { //get the single quote item required quoteitem = await ct.QuoteItem.AsNoTracking().SingleOrDefaultAsync(x => x.Id == wid.ChildItemId); switch (ayaType) { case AyaType.QuoteItemExpense: quoteitem.Expenses.Add(await ct.QuoteItemExpense.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); break; case AyaType.QuoteItemLabor: quoteitem.Labors.Add(await ct.QuoteItemLabor.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); break; case AyaType.QuoteItemLoan: quoteitem.Loans.Add(await ct.QuoteItemLoan.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); break; case AyaType.QuoteItemPart: quoteitem.Parts.Add(await ct.QuoteItemPart.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); break; case AyaType.QuoteItemScheduledUser: quoteitem.ScheduledUsers.Add(await ct.QuoteItemScheduledUser.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); break; case AyaType.QuoteItemTask: quoteitem.Tasks.Add(await ct.QuoteItemTask.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); break; case AyaType.QuoteItemTravel: quoteitem.Travels.Add(await ct.QuoteItemTravel.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); break; case AyaType.QuoteItemOutsideService: quoteitem.OutsideServices.Add(await ct.QuoteItemOutsideService.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); break; case AyaType.QuoteItemUnit: quoteitem.Units.Add(await ct.QuoteItemUnit.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); break; } } if (quoteitem != null) ret.Items.Add(quoteitem); await QuotePopulateVizFields(ret, false, populateForReporting); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //REPORTING // public async Task GetReportData(DataListSelectedRequest dataListSelectedRequest, Guid jobId) { //quote reports for entire quote or just sub parts all go through here //if the ayatype is a descendant of the quote then only the portion of the quote from that descendant directly up to the header will be populated and returned //however if the report template has includeWoItemDescendants=true then the woitems is fully populated var idList = dataListSelectedRequest.SelectedRowIds; JArray ReportData = new JArray(); List batchResults = new List(); while (idList.Any()) { var batch = idList.Take(IReportAbleObject.REPORT_DATA_BATCH_SIZE); idList = idList.Skip(IReportAbleObject.REPORT_DATA_BATCH_SIZE).ToArray(); batchResults.Clear(); foreach (long batchId in batch) { if (!ReportRenderManager.KeepGoing(jobId)) return null; batchResults.Add(await QuoteGetPartialAsync(dataListSelectedRequest.AType, batchId, dataListSelectedRequest.IncludeWoItemDescendants, true)); } foreach (Quote w in batchResults) { if (!ReportRenderManager.KeepGoing(jobId)) return null; var jo = JObject.FromObject(w); //Quote header custom fields if (!JsonUtil.JTokenIsNullOrEmpty(jo["CustomFields"])) jo["CustomFields"] = JObject.Parse((string)jo["CustomFields"]); //QuoteItem custom fields foreach (JObject jItem in jo["Items"]) { if (!JsonUtil.JTokenIsNullOrEmpty(jItem["CustomFields"])) jItem["CustomFields"] = JObject.Parse((string)jItem["CustomFields"]); //QuoteItemUnit custom fields foreach (JObject jUnit in jItem["Units"]) { if (!JsonUtil.JTokenIsNullOrEmpty(jUnit["CustomFields"])) jUnit["CustomFields"] = JObject.Parse((string)jUnit["CustomFields"]); } } ReportData.Add(jo); } } vc.Clear(); oc.Clear(); return ReportData; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task QuotePopulateVizFields(Quote o, bool headerOnly, bool populateForReporting) { o.UserIsRestrictedType = UserIsRestrictedType; o.UserIsTechRestricted = UserIsTechRestricted; o.UserIsSubContractorFull = UserIsSubContractorFull; o.UserIsSubContractorRestricted = UserIsSubContractorRestricted; o.UserCanViewPartCosts = UserCanViewPartCosts; o.UserCanViewLaborOrTravelRateCosts = UserCanViewLaborOrTravelRateCosts; o.UserCanViewLoanerCosts = UserCanViewLoanerCosts; if (!headerOnly) { foreach (var v in o.States) await StatePopulateVizFields(v); foreach (var v in o.Items) await ItemPopulateVizFields(v, populateForReporting); } //Alert notes //Customer notes first then others below { if (vc.Get("wocustname", o.CustomerId) == null)//will always be present so no need to check other values { var custInfo = await ct.Customer.AsNoTracking().Where(x => x.Id == o.CustomerId).Select(x => new { x.AlertNotes, x.TechNotes, x.Name, x.Phone1, x.Phone2, x.Phone3, x.Phone4, x.Phone5, x.EmailAddress }).FirstOrDefaultAsync(); vc.Add(custInfo.Name, "wocustname", o.CustomerId); if (!string.IsNullOrWhiteSpace(custInfo.AlertNotes)) { vc.Add($"{await Translate("Customer")} - {await Translate("AlertNotes")}\n{custInfo.AlertNotes}\n\n", "woalert", o.CustomerId); } else { vc.Add(string.Empty, "woalert", o.CustomerId); } if (!string.IsNullOrWhiteSpace(custInfo.TechNotes)) { vc.Add(custInfo.TechNotes, "custtechnotes", o.CustomerId); } vc.Add(custInfo.Phone1, "custphone1", o.CustomerId); vc.Add(custInfo.Phone2, "custphone2", o.CustomerId); vc.Add(custInfo.Phone3, "custphone3", o.CustomerId); vc.Add(custInfo.Phone4, "custphone4", o.CustomerId); vc.Add(custInfo.Phone5, "custphone5", o.CustomerId); vc.Add(custInfo.EmailAddress, "custemail", o.CustomerId); } o.CustomerViz = vc.Get("wocustname", o.CustomerId); o.AlertViz = vc.Get("woalert", o.CustomerId); o.CustomerTechNotesViz = vc.Get("custtechnotes", o.CustomerId); o.CustomerPhone1Viz = vc.Get("custphone1", o.CustomerId); o.CustomerPhone2Viz = vc.Get("custphone2", o.CustomerId); o.CustomerPhone3Viz = vc.Get("custphone3", o.CustomerId); o.CustomerPhone4Viz = vc.Get("custphone4", o.CustomerId); o.CustomerPhone5Viz = vc.Get("custphone5", o.CustomerId); o.CustomerEmailAddressViz = vc.Get("custemail", o.CustomerId); } if (o.ProjectId != null) { string value = vc.Get("projname", o.ProjectId); if (value == null) { value = await ct.Project.AsNoTracking().Where(x => x.Id == o.ProjectId).Select(x => x.Name).FirstOrDefaultAsync(); vc.Add(value, "projname", o.ProjectId); } o.ProjectViz = value; } if (o.PreparedById != null) { if (!vc.Has("user", o.PreparedById)) { vc.Add(await ct.User.AsNoTracking().Where(x => x.Id == o.PreparedById).Select(x => x.Name).FirstOrDefaultAsync(), "user", o.PreparedById); } o.PreparedByViz = vc.Get("user", o.PreparedById); } if (o.ContractId != null) { if (vc.Get("ctrctname", o.ContractId) == null) { var contractVizFields = await ct.Contract.AsNoTracking().Where(x => x.Id == o.ContractId).Select(x => new { Name = x.Name, AlertNotes = x.AlertNotes }).FirstOrDefaultAsync(); vc.Add(contractVizFields.Name, "ctrctname", o.ContractId); vc.Add(contractVizFields.AlertNotes, "ctrctalrt", o.ContractId); } o.ContractViz = vc.Get("ctrctname", o.ContractId);//contractVizFields.Name; var alrtNotes = vc.Get("ctrctalrt", o.ContractId); if (!string.IsNullOrWhiteSpace(alrtNotes)) { o.AlertViz += $"{await Translate("Contract")}\n{alrtNotes}\n\n"; } } else o.ContractViz = "-"; if (o.LastStatusId != null && o.States.Count > 0) { var lastState = o.States[o.States.Count - 1]; o.LastStateColorViz = lastState.ColorViz; o.LastStateCompletedViz = lastState.CompletedViz; o.LastStateLockedViz = lastState.LockedViz; o.LastStateNameViz = lastState.NameViz; o.LastStateUserViz = lastState.UserViz; } } //////////////////////////////////////////////////////////////////////////////////////////////// // IMPORT EXPORT // public async Task GetExportData(DataListSelectedRequest dataListSelectedRequest, Guid jobId) { //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, jobId); } //////////////////////////////////////////////////////////////////////////////////////////////// //JOB / OPERATIONS // public async Task HandleJobAsync(OpsJob job) { switch (job.JobType) { case JobType.BatchCoreObjectOperation: await ProcessBatchJobAsync(job); break; default: throw new System.ArgumentOutOfRangeException($"Quote.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.Quote.Select(z => z.Id).ToListAsync(); bool SaveIt = false; //--------------------------------- //case 4192 TimeSpan ProgressAndCancelCheckSpan = new TimeSpan(0, 0, ServerBootConfig.JOB_PROGRESS_UPDATE_AND_CANCEL_CHECK_SECONDS); DateTime LastProgressCheck = DateTime.UtcNow.Subtract(new TimeSpan(1, 1, 1, 1, 1)); var TotalRecords = idList.LongCount(); long CurrentRecord = -1; //--------------------------------- foreach (long id in idList) { try { //-------------------------------- //case 4192 //Update progress / cancel requested? CurrentRecord++; if (DateUtil.IsAfterDuration(LastProgressCheck, ProgressAndCancelCheckSpan)) { await JobsBiz.UpdateJobProgressAsync(job.GId, $"{CurrentRecord}/{TotalRecords}"); if (await JobsBiz.GetJobStatusAsync(job.GId) == JobStatus.CancelRequested) break; LastProgressCheck = DateTime.UtcNow; } //--------------------------------- SaveIt = false; ClearErrors(); ICoreBizObjectModel o = null; //save a fetch if it's a delete if (job.SubType != JobSubType.Delete) o = await GetQuoteGraphItem(job.AType, id); 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 DeleteQuoteGraphItem(job.AType, 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 PutQuoteGraphItem(job.AType, o); if (o == null) { await JobsBiz.LogJobAsync(job.GId, $"LT:Errors {GetErrorsAsString()} id {id}"); FailedObjectCount++; } } //delay so we're not tying up all the resources in a tight loop await Task.Delay(AyaNova.Util.ServerBootConfig.JOB_OBJECT_HANDLE_BATCH_JOB_LOOP_DELAY); } catch (Exception ex) { await JobsBiz.LogJobAsync(job.GId, $"LT:Errors id({id})"); await JobsBiz.LogJobAsync(job.GId, ExceptionUtil.ExtractAllExceptionMessages(ex)); } } //--------------------------------- //case 4192 await JobsBiz.UpdateJobProgressAsync(job.GId, $"{++CurrentRecord}/{TotalRecords}"); //--------------------------------- 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 QuoteHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{this.BizType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; Quote oProposed = (Quote)proposedObj; //STANDARD EVENTS FOR ALL OBJECTS await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct);//Note: will properly handle all delete events and event removal if deleted //SPECIFIC EVENTS FOR THIS OBJECT }//end of process notifications #endregion quote level /* ███████╗████████╗ █████╗ ████████╗███████╗███████╗ ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝██╔════╝ ███████╗ ██║ ███████║ ██║ █████╗ ███████╗ ╚════██║ ██║ ██╔══██║ ██║ ██╔══╝ ╚════██║ ███████║ ██║ ██║ ██║ ██║ ███████╗███████║ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝ */ #region QuoteState level //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task StateExistsAsync(long id) { return await ct.QuoteState.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task StateCreateAsync(QuoteState newObject) { using (var transaction = await ct.Database.BeginTransactionAsync())//case 4240 wrapped in transaction because header is updated as well as states collection so both need to be in sync { await StatePreliminaryValidateCanAddAsync(newObject); if (HasErrors) return null; else { var quote = await ct.Quote.FirstOrDefaultAsync(x => x.Id == newObject.QuoteId); var NewStatusInfo = await ct.QuoteStatus.AsNoTracking().FirstOrDefaultAsync(x => x.Id == newObject.QuoteStatusId); QuoteStatus LastStatusInfo = null; if (quote.LastStatusId != null) LastStatusInfo = await ct.QuoteStatus.AsNoTracking().FirstOrDefaultAsync(x => x.Id == quote.LastStatusId); //Level 2 validation - Quote status RemoveRoles, SelectRoles and User roles //If we have a last role, can it be removed by this User? if (LastStatusInfo != null && CurrentUserRoles.HasAnyFlags(LastStatusInfo.RemoveRoles) == false) { AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror", "LT:QuoteQuoteStatusType -> LT:RemoveRoles"); return null; } //Can the new role be selected by this user? if (CurrentUserRoles.HasAnyFlags(NewStatusInfo.SelectRoles) == false) { AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror", "LT:QuoteQuoteStatusType -> LT:SelectRoles"); return null; } //Seems legit, we'll allow it await ct.QuoteState.AddAsync(newObject); quote.LastStatusId = newObject.QuoteStatusId; await ct.SaveChangesAsync(); newObject.NewQuoteConcurrency = quote.Concurrency; await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, AyaType.QuoteStatus, AyaEvent.Created), ct); await transaction.CommitAsync(); await StateHandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } } //////////////////////////////////////////////////////////////////////////////////////////////// // GET // internal async Task StateGetAsync(long id, bool logTheGetEvent = true) { //Note: there could be rules checking here in future, i.e. can only get own quote or something //if so, then need to implement AddError and in route handle Null return with Error check just like PUT route does now //https://docs.microsoft.com/en-us/ef/core/querying/related-data //docs say this will not query twice but will recognize the duplicate woitem bit which is required for multiple grandchild collections var ret = await ct.QuoteState.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, AyaType.QuoteStatus, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task StatePopulateVizFields(QuoteState o) { if (o.UserId != 0) { if (!vc.Has("user", o.UserId)) { vc.Add(await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(), "user", o.UserId); } o.UserViz = vc.Get("user", o.UserId); } QuoteStatus QStatus = null; if (!oc.Has("quotestatus", o.QuoteStatusId)) { QStatus = await ct.QuoteStatus.AsNoTracking().Where(x => x.Id == o.QuoteStatusId).FirstOrDefaultAsync(); oc.Add(QStatus, "quotestatus", o.QuoteStatusId); } else QStatus = (QuoteStatus)oc.Get("quotestatus", o.QuoteStatusId); o.NameViz = QStatus.Name; o.ColorViz = QStatus.Color; o.CompletedViz = QStatus.Completed; o.LockedViz = QStatus.Locked; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // (note: this would only ever be called when a quote is deleted, there is no direct delete) internal async Task StateDeleteAsync(long workOrderId, IDbContextTransaction parentTransaction) { var stateList = await ct.QuoteState.AsNoTracking().Where(z => z.QuoteId == workOrderId).ToListAsync(); foreach (var wostate in stateList) { ct.QuoteState.Remove(wostate); await ct.SaveChangesAsync(); //no need to call this because it's only going to run this method if the quote is deleted and //via process standard notifciation events for quote deletion will remove any state delayed notifications anyway so //nothing to call or do here related to notification // await StateHandlePotentialNotificationEvent(AyaEvent.Deleted, wostate); } return true; } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // //NOTE: states are only ever *added* to a quote order when validation is called //never deleted, there is deeper validation needed for states related to roles etc //so this validation just does the preliminary check to see if a change of state is even possible //before further processing inside the actual update code in the caller // private async Task StatePreliminaryValidateCanAddAsync(QuoteState proposedObj) { //of all restricted users, only a restricted tech can change status if (UserIsSubContractorFull || UserIsSubContractorRestricted) { AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } //does it have a valid quote id if (proposedObj.QuoteId == 0) AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteId"); else if (!await QuoteExistsAsync(proposedObj.QuoteId)) { AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteId"); } } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task StateHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; QuoteState oProposed = (QuoteState)proposedObj; var QuoteInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == oProposed.QuoteId).Select(x => new { x.Serial, x.Tags, x.CustomerId }).FirstOrDefaultAsync(); QuoteStatus qos = await ct.QuoteStatus.AsNoTracking().FirstOrDefaultAsync(x => x.Id == oProposed.QuoteStatusId); //for notification purposes because has no name / tags field itself oProposed.Name = QuoteInfo.Serial.ToString(); oProposed.Tags = QuoteInfo.Tags; //STANDARD EVENTS FOR ALL OBJECTS //NONE: state notifications are specific and not the same as for general objects so don't process standard events //SPECIFIC EVENTS FOR THIS OBJECT //QuoteStatusChange = 9,//* Quote object, any *change* of status including from no status (new) to a specific conditional status ID value // QuoteStatusAge = 29,//* Quote object Created / Updated, conditional on exact status selected IdValue, Tags conditional, advance notice can be set //NOTE: ID, state notifications are for the Quote, not the state itself unlike other objects, so use the quote type and ID here for all notifications //## DELETED EVENTS //A state cannot be deleted so nothing to handle that is required //a quote CAN be deleted and it will automatically remove all events for it so also no need to remove time delayed status events either if wo is deleted. //so in essence there is nothing to be done regarding deleted events with states in a blanket way, however specific events below may remove them as appropriate //## CREATED (this is the only possible notification CREATION ayaEvent type for a quote state as they are create only) if (ayaEvent == AyaEvent.Created) { //# STATUS CHANGE (create new status) { //PERSONAL SUBSCCRIPTION //Conditions: must match specific status id value and also tags below //delivery is immediate so no need to remove old ones of this kind var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.QuoteStatusChange && z.IdValue == oProposed.QuoteStatusId).ToListAsync(); foreach (var sub in subs) { //not for inactive users if (!await UserBiz.UserIsActive(sub.UserId)) continue; //Tag match? (will be true if no sub tags so always safe to call this) if (NotifyEventHelper.ObjectHasAllSubscriptionTags(QuoteInfo.Tags, sub.Tags)) { NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.QuoteStatusChange, UserId = sub.UserId, AyaType = AyaType.Quote, ObjectId = oProposed.QuoteId, NotifySubscriptionId = sub.Id, Name = $"{QuoteInfo.Serial.ToString()} - {qos.Name}" }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); await ct.SaveChangesAsync(); } } }//quote status change event { //PROXY CUSTOMER NOTIFICATION SUBSCRIPTION HANDLING //can this customer even be delivered to? var custInfo = await ct.Customer.AsNoTracking().Where(x => x.Id == QuoteInfo.CustomerId).Select(x => new { x.Active, x.Tags, x.EmailAddress }).FirstOrDefaultAsync(); if (custInfo != null && custInfo.Active && !string.IsNullOrWhiteSpace(custInfo.EmailAddress)) { //Conditions: must match specific status id value and also tags below //delivery is immediate so no need to remove old ones of this kind //note order by id ascending so that only the oldest notification "wins" as per docs in case of overlap to same customer var subs = await ct.CustomerNotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.QuoteStatusChange && z.IdValue == oProposed.QuoteStatusId).OrderBy(z => z.Id).ToListAsync(); foreach (var sub in subs) { //Object tags must match and Customer tags must match if (NotifyEventHelper.ObjectHasAllSubscriptionTags(QuoteInfo.Tags, sub.Tags) && NotifyEventHelper.ObjectHasAllSubscriptionTags(custInfo.Tags, sub.CustomerTags)) { CustomerNotifyEvent n = new CustomerNotifyEvent() { EventType = NotifyEventType.QuoteStatusChange, CustomerId = QuoteInfo.CustomerId, AyaType = AyaType.Quote, ObjectId = oProposed.QuoteId, CustomerNotifySubscriptionId = sub.Id, Name = QuoteInfo.Serial.ToString() }; await ct.CustomerNotifyEvent.AddAsync(n); log.LogDebug($"Adding CustomerNotifyEvent: [{n.ToString()}]"); await ct.SaveChangesAsync(); break;//we have a match no need to process any further subs for this event } } } }//quote status change event //# STATUS AGE { //QuoteStatusAge = 29,//* Quote STATUS unchanged for set time (stuck in state), conditional on: Duration (how long stuck), exact status selected IdValue, Tags. Advance notice can NOT be set //Always clear any old ones for this object as they are all irrelevant the moment the state has changed: await NotifyEventHelper.ClearPriorEventsForObject(ct, AyaType.Quote, proposedObj.Id, NotifyEventType.QuoteStatusAge); var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.QuoteStatusAge && z.IdValue == oProposed.QuoteStatusId).ToListAsync(); foreach (var sub in subs) { //not for inactive users if (!await UserBiz.UserIsActive(sub.UserId)) continue; //Quote Tag match? (Not State, state has no tags, will be true if no sub tags so always safe to call this) if (NotifyEventHelper.ObjectHasAllSubscriptionTags(QuoteInfo.Tags, sub.Tags)) { NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.QuoteStatusAge, UserId = sub.UserId, AyaType = AyaType.Quote, ObjectId = oProposed.QuoteId, NotifySubscriptionId = sub.Id, Name = $"{QuoteInfo.Serial.ToString()} - {qos.Name}" }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); await ct.SaveChangesAsync(); } } }//quote status age event } }//end of process notifications #endregion work order STATE level /* ██╗████████╗███████╗███╗ ███╗███████╗ ██║╚══██╔══╝██╔════╝████╗ ████║██╔════╝ ██║ ██║ █████╗ ██╔████╔██║███████╗ ██║ ██║ ██╔══╝ ██║╚██╔╝██║╚════██║ ██║ ██║ ███████╗██║ ╚═╝ ██║███████║ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚══════╝ */ #region QuoteItem level //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task ItemExistsAsync(long id) { return await ct.QuoteItem.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task ItemCreateAsync(QuoteItem newObject) { await ItemValidateAsync(newObject, null); if (HasErrors) return null; else { newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); await ct.QuoteItem.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, AyaType.QuoteItem, AyaEvent.Created), ct); await ItemSearchIndexAsync(newObject, true); await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); await ItemPopulateVizFields(newObject, false); await ItemHandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// // GET // internal async Task ItemGetAsync(long id, bool logTheGetEvent = true) { //Restricted users can not fetch a woitem directly //arbitrary decision so don't have to put in all the cleanup code //because from our own UI they wouldn't fetch this anyway and //so this is only to cover api use by 3rd parties if (UserIsRestrictedType) { return null; } //Note: there could be rules checking here in future, i.e. can only get own quote or something //if so, then need to implement AddError and in route handle Null return with Error check just like PUT route does now //https://docs.microsoft.com/en-us/ef/core/querying/related-data //docs say this will not query twice but will recognize the duplicate woitem bit which is required for multiple grandchild collections var ret = await ct.QuoteItem.AsSplitQuery().AsNoTracking() .Include(wi => wi.Expenses) .Include(wi => wi.Labors) .Include(wi => wi.Loans) .Include(wi => wi.Parts) .Include(wi => wi.ScheduledUsers) .Include(wi => wi.Tasks) .Include(wi => wi.Travels) .Include(wi => wi.Units) .Include(wi => wi.OutsideServices) .SingleOrDefaultAsync(z => z.Id == id); if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, AyaType.QuoteItem, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task ItemPutAsync(QuoteItem putObject) { //Note: this is intentionally not using the getasync because //doing so would also fetch the children which would then get deleted on save since putobject has no children var dbObject = await ct.QuoteItem.AsNoTracking().FirstOrDefaultAsync(z => z.Id == putObject.Id); 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(putObject.Tags); putObject.CustomFields = JsonUtil.CompactJson(putObject.CustomFields); await ItemValidateAsync(putObject, dbObject); if (HasErrors) return null; ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await ItemExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, putObject.Id, AyaType.QuoteItem, AyaEvent.Modified), ct); await ItemSearchIndexAsync(putObject, false); await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags); await ItemPopulateVizFields(putObject, false); await ItemHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task ItemDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); var dbObject = await ct.QuoteItem.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND); return false; } ItemValidateCanDelete(dbObject); if (HasErrors) return false; //collect the child id's to delete var ExpenseIds = await ct.QuoteItemExpense.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); var LaborIds = await ct.QuoteItemLabor.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); var LoanIds = await ct.QuoteItemLoan.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); var PartIds = await ct.QuoteItemPart.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); var ScheduledUserIds = await ct.QuoteItemScheduledUser.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); var TaskIds = await ct.QuoteItemTask.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); var TravelIds = await ct.QuoteItemTravel.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); var UnitIds = await ct.QuoteItemUnit.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); var OutsideServiceIds = await ct.QuoteItemOutsideService.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); //Delete children foreach (long ItemId in ExpenseIds) if (!await ExpenseDeleteAsync(ItemId, transaction)) return false; foreach (long ItemId in LaborIds) if (!await LaborDeleteAsync(ItemId, transaction)) return false; foreach (long ItemId in LoanIds) if (!await LoanDeleteAsync(ItemId, transaction)) return false; foreach (long ItemId in PartIds) if (!await PartDeleteAsync(ItemId, transaction)) return false; foreach (long ItemId in ScheduledUserIds) if (!await ScheduledUserDeleteAsync(ItemId, transaction)) return false; foreach (long ItemId in TaskIds) if (!await TaskDeleteAsync(ItemId, transaction)) return false; foreach (long ItemId in TravelIds) if (!await TravelDeleteAsync(ItemId, transaction)) return false; foreach (long ItemId in UnitIds) if (!await UnitDeleteAsync(ItemId, transaction)) return false; foreach (long ItemId in OutsideServiceIds) if (!await OutsideServiceDeleteAsync(ItemId, transaction)) return false; ct.QuoteItem.Remove(dbObject); await ct.SaveChangesAsync(); //Log event await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "wo:" + dbObject.QuoteId.ToString(), ct);//FIX wo?? Not sure what is best here; revisit await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); //all good do the commit if it's ours if (parentTransaction == null) await transaction.CommitAsync(); await ItemHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); return true; } private async Task ItemSearchIndexAsync(QuoteItem obj, bool isNew) { //SEARCH INDEXING var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, AyaType.QuoteItem); SearchParams.AddText(obj.Notes).AddText(obj.TechNotes).AddText(obj.Wiki).AddText(obj.Tags).AddCustomFields(obj.CustomFields); if (isNew) await Search.ProcessNewObjectKeywordsAsync(SearchParams); else await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); } public async Task ItemGetSearchResultSummary(long id) { var obj = await ct.QuoteItem.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id);//# Note, intentionally not calling ItemGetAsync here as don't want whole graph var SearchParams = new Search.SearchIndexProcessObjectParameters(); if (obj != null) SearchParams.AddText(obj.Notes).AddText(obj.TechNotes).AddText(obj.Wiki).AddText(obj.Tags).AddCustomFields(obj.CustomFields); return SearchParams; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task ItemPopulateVizFields(QuoteItem o, bool populateForReporting) { if (o.WorkOrderItemStatusId != null) { string value = vc.Get("woistatname", o.WorkOrderItemStatusId); if (value == null) { var StatusInfo = await ct.WorkOrderItemStatus.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.WorkOrderItemStatusId); vc.Add(StatusInfo.Name, "woistatname", o.WorkOrderItemStatusId); vc.Add(StatusInfo.Color, "woistatcolor", o.WorkOrderItemStatusId); o.WorkOrderItemStatusNameViz = StatusInfo.Name; o.WorkOrderItemStatusColorViz = StatusInfo.Color; } else { o.WorkOrderItemStatusNameViz = value; o.WorkOrderItemStatusColorViz = vc.Get("woistatcolor", o.WorkOrderItemStatusId); } } if (o.WorkOrderItemPriorityId != null) { string value = vc.Get("woipriorityname", o.WorkOrderItemPriorityId); if (value == null) { var PriorityInfo = await ct.WorkOrderItemPriority.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.WorkOrderItemPriorityId); vc.Add(PriorityInfo.Name, "woipriorityname", o.WorkOrderItemPriorityId); vc.Add(PriorityInfo.Color, "woiprioritycolor", o.WorkOrderItemPriorityId); o.WorkOrderItemPriorityNameViz = PriorityInfo.Name; o.WorkOrderItemPriorityColorViz = PriorityInfo.Color; } else { o.WorkOrderItemPriorityNameViz = value; o.WorkOrderItemPriorityColorViz = vc.Get("woiprioritycolor", o.WorkOrderItemPriorityId); } } foreach (var v in o.Expenses) await ExpensePopulateVizFields(v); foreach (var v in o.Labors) await LaborPopulateVizFields(v); foreach (var v in o.Loans) await LoanPopulateVizFields(v); foreach (var v in o.OutsideServices) await OutsideServicePopulateVizFields(v); foreach (var v in o.Parts) await PartPopulateVizFields(v); foreach (var v in o.ScheduledUsers) await ScheduledUserPopulateVizFields(v); foreach (var v in o.Tasks) await TaskPopulateVizFields(v); foreach (var v in o.Travels) await TravelPopulateVizFields(v); foreach (var v in o.Units) await UnitPopulateVizFields(v, populateForReporting); } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task ItemValidateAsync(QuoteItem proposedObj, QuoteItem currentObj) { //run validation and biz rules bool isNew = currentObj == null; //does it have a valid quote id if (proposedObj.QuoteId == 0) AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteId"); else if (!await QuoteExistsAsync(proposedObj.QuoteId)) AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteId"); //Check restricted role preventing create if (isNew && UserIsRestrictedType) { AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return;//this is a completely disqualifying error } //Check state if updatable right now if (!isNew) { //Front end is coded to save the state first before any other updates if it has changed and it would not be //a part of this header update so it's safe to check it here as it will be most up to date var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); if (CurrentWoStatus.Locked) { AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); return;//this is a completely disqualifying error } } if (string.IsNullOrWhiteSpace(proposedObj.Notes)) AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "Notes"); //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.Quote.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);//note: this is passed only to add errors //validate custom fields CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields, "WorkOrderItemCustom"); } } private void ItemValidateCanDelete(QuoteItem obj) { if (obj == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return; } //Check restricted role preventing create if (UserIsRestrictedType) { AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return;//this is a completely disqualifying error } //re-check rights here necessary due to traversal delete from Principle object if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItem)) { AddError(ApiErrorCode.NOT_AUTHORIZED); return; } } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task ItemHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; QuoteItem oProposed = (QuoteItem)proposedObj; var qid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteId, ct); var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == qid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); //for notification purposes because has no name field itself if (WorkorderInfo != null) oProposed.Name = WorkorderInfo.Serial.ToString(); else oProposed.Name = "??"; //STANDARD EVENTS FOR ALL OBJECTS await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); //SPECIFIC EVENTS FOR THIS OBJECT }//end of process notifications #endregion work order item level /* ███████╗██╗ ██╗██████╗ ███████╗███╗ ██╗███████╗███████╗███████╗ ██╔════╝╚██╗██╔╝██╔══██╗██╔════╝████╗ ██║██╔════╝██╔════╝██╔════╝ █████╗ ╚███╔╝ ██████╔╝█████╗ ██╔██╗ ██║███████╗█████╗ ███████╗ ██╔══╝ ██╔██╗ ██╔═══╝ ██╔══╝ ██║╚██╗██║╚════██║██╔══╝ ╚════██║ ███████╗██╔╝ ██╗██║ ███████╗██║ ╚████║███████║███████╗███████║ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═══╝╚══════╝╚══════╝╚══════╝ */ #region QuoteItemExpense level //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task ExpenseExistsAsync(long id) { return await ct.QuoteItemExpense.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task ExpenseCreateAsync(QuoteItemExpense newObject) { await ExpenseValidateAsync(newObject, null); if (HasErrors) return null; else { await ct.QuoteItemExpense.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); await ExpenseSearchIndexAsync(newObject, true); await ExpensePopulateVizFields(newObject); await ExpenseHandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// // GET // internal async Task ExpenseGetAsync(long id, bool logTheGetEvent = true) { if (UserIsSubContractorFull || UserIsSubContractorRestricted) //no access allowed at all return null; var ret = await ct.QuoteItemExpense.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (UserIsTechRestricted && ret.UserId != UserId)//tech restricted can only see their own expenses { AddError(ApiErrorCode.NOT_AUTHORIZED); return null; } if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task ExpensePutAsync(QuoteItemExpense putObject) { var dbObject = await ExpenseGetAsync(putObject.Id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } if (dbObject.Concurrency != putObject.Concurrency) { AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await ExpenseValidateAsync(putObject, dbObject); if (HasErrors) return null; ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await ExpenseExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, putObject.Id, putObject.AyaType, AyaEvent.Modified), ct); await ExpenseSearchIndexAsync(putObject, false); await ExpensePopulateVizFields(putObject); await ExpenseHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task ExpenseDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); var dbObject = await ExpenseGetAsync(id, false); ExpenseValidateCanDelete(dbObject); if (HasErrors) return false; ct.QuoteItemExpense.Remove(dbObject); await ct.SaveChangesAsync(); //Log event await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "quoteitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); if (parentTransaction == null) await transaction.CommitAsync(); await ExpenseHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); return true; } ////////////////////////////////////////////// //INDEXING // private async Task ExpenseSearchIndexAsync(QuoteItemExpense obj, bool isNew) { //SEARCH INDEXING var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); SearchParams.AddText(obj.Name).AddText(obj.Description); if (isNew) await Search.ProcessNewObjectKeywordsAsync(SearchParams); else await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); } public async Task ExpenseGetSearchResultSummary(long id) { var obj = await ExpenseGetAsync(id, false); var SearchParams = new Search.SearchIndexProcessObjectParameters(); if (obj != null) SearchParams.AddText(obj.Description).AddText(obj.Name); return SearchParams; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task ExpensePopulateVizFields(QuoteItemExpense o, bool calculateTotalsOnly = false) { if (calculateTotalsOnly == false) { if (o.UserId != null) { if (!vc.Has("user", o.UserId)) { vc.Add(await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(), "user", o.UserId); } o.UserViz = vc.Get("user", o.UserId); } } TaxCode Tax = null; if (o.ChargeTaxCodeId != null) { if (!oc.Has("tax", o.ChargeTaxCodeId)) { Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == o.ChargeTaxCodeId); oc.Add(Tax, "tax", o.ChargeTaxCodeId); } else Tax = (TaxCode)oc.Get("tax", o.ChargeTaxCodeId); } if (Tax != null) o.TaxCodeViz = Tax.Name; //Calculate totals and taxes if (o.ChargeToCustomer) { o.TaxAViz = 0; o.TaxBViz = 0; if (Tax != null) { if (Tax.TaxAPct != 0) { o.TaxAViz = MoneyUtil.Round(o.ChargeAmount * (Tax.TaxAPct / 100)); } if (Tax.TaxBPct != 0) { if (Tax.TaxOnTax) { o.TaxBViz = MoneyUtil.Round((o.ChargeAmount + o.TaxAViz) * (Tax.TaxBPct / 100)); } else { o.TaxBViz = MoneyUtil.Round(o.ChargeAmount * (Tax.TaxBPct / 100)); } } o.LineTotalViz = o.ChargeAmount + o.TaxAViz + o.TaxBViz; } else { o.LineTotalViz = o.ChargeAmount + o.TaxPaid; } } } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task ExpenseValidateAsync(QuoteItemExpense proposedObj, QuoteItemExpense currentObj) { //skip validation if seeding // if(ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; //run validation and biz rules bool isNew = currentObj == null; if (UserIsSubContractorFull || UserIsSubContractorRestricted) { //no edits allowed AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } if (UserIsRestrictedType && (proposedObj.UserId != UserId || (!isNew && currentObj.UserId != UserId))) { //no edits allowed on other people's records AddError(ApiErrorCode.NOT_AUTHORIZED); return; } if (proposedObj.QuoteItemId == 0) { AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); return;//this is a completely disqualifying error } else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) { AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); return;//this is a completely disqualifying error } //Check state if updatable right now if (!isNew) { //Front end is coded to save the state first before any other updates if it has changed and it would not be //a part of this header update so it's safe to check it here as it will be most up to date var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); if (CurrentWoStatus.Locked) { AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); return;//this is a completely disqualifying error } } if (!isNew && UserIsTechRestricted) { //Existing record so just make sure they haven't changed the not changeable fields from the db version //Expenses: add (no user selection defaults to themselves), view, partial fields available // to edit or delete only where they are the selected user and only edit fields //Summary, Cost, Tax paid, Description //note that UI will prevent this, this rule is only backup for 3rd party api users if (currentObj.ChargeAmount != proposedObj.ChargeAmount) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ChargeAmount"); //if (currentObj.TaxPaid != proposedObj.TaxPaid) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "TaxPaid"); if (currentObj.ChargeTaxCodeId != proposedObj.ChargeTaxCodeId) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ChargeTaxCodeId"); if (currentObj.ReimburseUser != proposedObj.ReimburseUser) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ReimburseUser"); if (currentObj.ChargeToCustomer != proposedObj.ChargeToCustomer) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ChargeToCustomer"); } if (isNew && UserIsTechRestricted) { //NEW record, they are not allowed to set several fields so make sure they are still at their defaults /* from client new expense record: concurrency: 0, description: null, name: null, totalCost: 0, chargeAmount: 0, taxPaid: 0, chargeTaxCodeId: null, taxCodeViz: null, reimburseUser: false, userId: null, userViz: null, chargeToCustomer: false, isDirty: true, workOrderItemId: this.value.items[this.activeWoItemIndex].id, uid: Date.now() //used for */ if (proposedObj.ChargeAmount != 0) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ChargeAmount"); // if (proposedObj.TaxPaid != 0) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "TaxPaid"); if (proposedObj.ChargeTaxCodeId != null) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ChargeTaxCodeId"); if (proposedObj.ReimburseUser != false) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ReimburseUser"); if (proposedObj.ChargeToCustomer != false) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ChargeToCustomer"); } //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.Quote.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);//note: this is passed only to add errors //validate custom fields // CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields); } } private void ExpenseValidateCanDelete(QuoteItemExpense obj) { if (UserIsSubContractorFull || UserIsSubContractorRestricted) { //no edits allowed AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } if (obj == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return; } if (UserIsTechRestricted && obj.UserId != UserId) { //no edits allowed AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } //re-check rights here necessary due to traversal delete from Principle object if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemExpense)) { AddError(ApiErrorCode.NOT_AUTHORIZED); return; } } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task ExpenseHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; QuoteItemExpense oProposed = (QuoteItemExpense)proposedObj; var qid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == qid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); oProposed.Tags = WorkorderInfo.Tags; //STANDARD EVENTS FOR ALL OBJECTS await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); //SPECIFIC EVENTS FOR THIS OBJECT }//end of process notifications #endregion work order item EXPENSE level /* ██╗ █████╗ ██████╗ ██████╗ ██████╗ ██║ ██╔══██╗██╔══██╗██╔═══██╗██╔══██╗ ██║ ███████║██████╔╝██║ ██║██████╔╝ ██║ ██╔══██║██╔══██╗██║ ██║██╔══██╗ ███████╗██║ ██║██████╔╝╚██████╔╝██║ ██║ ╚══════╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝ */ #region QuoteItemLabor level //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task LaborExistsAsync(long id) { return await ct.QuoteItemLabor.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task LaborCreateAsync(QuoteItemLabor newObject) { await LaborValidateAsync(newObject, null); if (HasErrors) return null; else { await ct.QuoteItemLabor.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); await LaborSearchIndexAsync(newObject, true); await LaborPopulateVizFields(newObject); await LaborHandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// // GET // internal async Task LaborGetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.QuoteItemLabor.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (UserIsRestrictedType && ret.UserId != UserId) { AddError(ApiErrorCode.NOT_AUTHORIZED); return null; } if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task LaborPutAsync(QuoteItemLabor putObject) { var dbObject = await LaborGetAsync(putObject.Id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } if (dbObject.Concurrency != putObject.Concurrency) { AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await LaborValidateAsync(putObject, dbObject); if (HasErrors) return null; ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await LaborExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, putObject.AyaType, AyaEvent.Modified), ct); await LaborSearchIndexAsync(putObject, false); await LaborPopulateVizFields(putObject); await LaborHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task LaborDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); var dbObject = await LaborGetAsync(id, false); LaborValidateCanDelete(dbObject); if (HasErrors) return false; ct.QuoteItemLabor.Remove(dbObject); await ct.SaveChangesAsync(); //Log event await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "quoteitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); if (parentTransaction == null) await transaction.CommitAsync(); await LaborHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); return true; } ////////////////////////////////////////////// //INDEXING // private async Task LaborSearchIndexAsync(QuoteItemLabor obj, bool isNew) { //SEARCH INDEXING var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); SearchParams.AddText(obj.ServiceDetails); if (isNew) await Search.ProcessNewObjectKeywordsAsync(SearchParams); else await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); } public async Task LaborGetSearchResultSummary(long id) { var obj = await LaborGetAsync(id, false); var SearchParams = new Search.SearchIndexProcessObjectParameters(); if (obj != null) SearchParams.AddText(obj.ServiceDetails); return SearchParams; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task LaborPopulateVizFields(QuoteItemLabor o, bool calculateTotalsOnly = false) { if (calculateTotalsOnly == false) { if (o.UserId != null) { if (!vc.Has("user", o.UserId)) { vc.Add(await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(), "user", o.UserId); } o.UserViz = vc.Get("user", o.UserId); } } ServiceRate Rate = null; if (o.ServiceRateId != null) { if (!oc.Has("servicerate", o.ServiceRateId)) { Rate = await ct.ServiceRate.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.ServiceRateId); oc.Add(Rate, "servicerate", o.ServiceRateId); } else Rate = (ServiceRate)oc.Get("servicerate", o.ServiceRateId); o.ServiceRateViz = Rate.Name; } TaxCode Tax = null; if (o.TaxCodeSaleId != null) { if (!oc.Has("tax", o.TaxCodeSaleId)) { Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == o.TaxCodeSaleId); oc.Add(Tax, "tax", o.TaxCodeSaleId); } else Tax = (TaxCode)oc.Get("tax", o.TaxCodeSaleId); } if (Tax != null) o.TaxCodeViz = Tax.Name; o.PriceViz = 0; if (Rate != null) { o.CostViz = Rate.Cost; o.ListPriceViz = Rate.Charge; o.UnitOfMeasureViz = Rate.Unit; o.PriceViz = Rate.Charge;//default price used if not manual or contract override } //manual price overrides anything if (o.PriceOverride != null) o.PriceViz = (decimal)o.PriceOverride; else { //not manual so could potentially have a contract adjustment var c = await GetCurrentQuoteContractFromRelatedAsync(AyaType.QuoteItem, o.QuoteItemId); if (c != null) { decimal pct = 0; ContractOverrideType cot = ContractOverrideType.PriceDiscount; bool TaggedAdjustmentInEffect = false; //POTENTIAL CONTRACT ADJUSTMENTS //First check if there is a matching tagged service rate contract discount, that takes precedence if (c.ContractServiceRateOverrideItems.Count > 0 && Rate != null) { //Iterate all contract tagged items in order of ones with the most tags first foreach (var csr in c.ContractServiceRateOverrideItems.OrderByDescending(z => z.Tags.Count)) if (csr.Tags.All(z => Rate.Tags.Any(x => x == z))) { if (csr.OverridePct != 0) { pct = csr.OverridePct / 100; cot = csr.OverrideType; TaggedAdjustmentInEffect = true; } } } //Generic discount? if (!TaggedAdjustmentInEffect && c.ServiceRatesOverridePct != 0) { pct = c.ServiceRatesOverridePct / 100; cot = c.ServiceRatesOverrideType; } //apply if discount found if (pct != 0) { if (cot == ContractOverrideType.CostMarkup) o.PriceViz = MoneyUtil.Round(o.CostViz + (o.CostViz * pct)); else if (cot == ContractOverrideType.PriceDiscount) o.PriceViz = MoneyUtil.Round(o.ListPriceViz - (o.ListPriceViz * pct)); } } } //Calculate totals and taxes //NET o.NetViz = MoneyUtil.Round(o.PriceViz * o.ServiceRateQuantity); //TAX o.TaxAViz = 0; o.TaxBViz = 0; if (Tax != null) { if (Tax.TaxAPct != 0) { o.TaxAViz = MoneyUtil.Round(o.NetViz * (Tax.TaxAPct / 100)); } if (Tax.TaxBPct != 0) { if (Tax.TaxOnTax) { o.TaxBViz = MoneyUtil.Round((o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100)); } else { o.TaxBViz = MoneyUtil.Round(o.NetViz * (Tax.TaxBPct / 100)); } } } o.LineTotalViz = o.NetViz + o.TaxAViz + o.TaxBViz; //RESTRICTIONS ON COST VISIBILITY? if (!UserCanViewLaborOrTravelRateCosts) { o.CostViz = 0; } } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task LaborValidateAsync(QuoteItemLabor proposedObj, QuoteItemLabor currentObj) { //skip validation if seeding // if(ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; //run validation and biz rules bool isNew = currentObj == null; if (proposedObj.QuoteItemId == 0) { AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); return;//this is a completely disqualifying error } else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) { AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); return;//this is a completely disqualifying error } //Check state if updatable right now if (!isNew) { //Front end is coded to save the state first before any other updates if it has changed and it would not be //a part of this header update so it's safe to check it here as it will be most up to date var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); if (CurrentWoStatus.Locked) { AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); return;//this is a completely disqualifying error } } if (UserIsRestrictedType && (proposedObj.UserId != UserId || (!isNew && currentObj.UserId != UserId))) { //no edits allowed on other people's records AddError(ApiErrorCode.NOT_AUTHORIZED); return; } //Start date AND end date must both be null or both contain values if (proposedObj.ServiceStartDate == null && proposedObj.ServiceStopDate != null) AddError(ApiErrorCode.VALIDATION_REQUIRED, "ServiceStartDate"); if (proposedObj.ServiceStartDate != null && proposedObj.ServiceStopDate == null) AddError(ApiErrorCode.VALIDATION_REQUIRED, "ServiceStopDate"); //Start date before end date if (proposedObj.ServiceStartDate != null && proposedObj.ServiceStopDate != null) if (proposedObj.ServiceStartDate > proposedObj.ServiceStopDate) AddError(ApiErrorCode.VALIDATION_STARTDATE_AFTER_ENDDATE, "ServiceStartDate"); //case 4171 removed // if (proposedObj.ServiceRateQuantity < 0)//negative quantities are not allowed // AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "ServiceRateQuantity"); // if (proposedObj.NoChargeQuantity < 0)//negative quantities are not allowed // AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "NoChargeQuantity"); //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.Quote.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);//note: this is passed only to add errors //validate custom fields } } private void LaborValidateCanDelete(QuoteItemLabor obj) { if (obj == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return; } if (UserIsRestrictedType) { //Labors: add (no user selection defaults to themselves), remove, view and edit only when they are the selected User if (obj.UserId != UserId) AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } //re-check rights here necessary due to traversal delete from Principle object if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemLabor)) { AddError(ApiErrorCode.NOT_AUTHORIZED); return; } } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task LaborHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; QuoteItemLabor oProposed = (QuoteItemLabor)proposedObj; var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); //for notification purposes because has no name or tags field itself oProposed.Name = WorkorderInfo.Serial.ToString(); oProposed.Tags = WorkorderInfo.Tags; //STANDARD EVENTS FOR ALL OBJECTS await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); //SPECIFIC EVENTS FOR THIS OBJECT }//end of process notifications #endregion work order item LABOR level /* ██╗ ██████╗ █████╗ ███╗ ██╗ ██║ ██╔═══██╗██╔══██╗████╗ ██║ ██║ ██║ ██║███████║██╔██╗ ██║ ██║ ██║ ██║██╔══██║██║╚██╗██║ ███████╗╚██████╔╝██║ ██║██║ ╚████║ */ #region QuoteItemLoan level //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task LoanExistsAsync(long id) { return await ct.QuoteItemLoan.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task LoanCreateAsync(QuoteItemLoan newObject) { await LoanValidateAsync(newObject, null); if (HasErrors) return null; else { await LoanBizActionsAsync(AyaEvent.Created, newObject, null, null); await ct.QuoteItemLoan.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); await LoanSearchIndexAsync(newObject, true); await LoanPopulateVizFields(newObject); await LoanHandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// // GET // internal async Task LoanGetAsync(long id, bool logTheGetEvent = true) { if (UserIsSubContractorRestricted) //no access allowed at all return null; var ret = await ct.QuoteItemLoan.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task LoanPutAsync(QuoteItemLoan putObject) { var dbObject = await LoanGetAsync(putObject.Id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } if (dbObject.Concurrency != putObject.Concurrency) { AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await LoanValidateAsync(putObject, dbObject); if (HasErrors) return null; await LoanBizActionsAsync(AyaEvent.Modified, putObject, dbObject, null); ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await LoanExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, putObject.AyaType, AyaEvent.Modified), ct); await LoanSearchIndexAsync(putObject, false); await LoanPopulateVizFields(putObject); await LoanHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task LoanDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); var dbObject = await LoanGetAsync(id, false); LoanValidateCanDelete(dbObject); if (HasErrors) return false; ct.QuoteItemLoan.Remove(dbObject); await ct.SaveChangesAsync(); //Log event await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "quoteitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); if (parentTransaction == null) await transaction.CommitAsync(); await LoanHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); return true; } ////////////////////////////////////////////// //INDEXING // private async Task LoanSearchIndexAsync(QuoteItemLoan obj, bool isNew) { //SEARCH INDEXING var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); SearchParams.AddText(obj.Notes); if (isNew) await Search.ProcessNewObjectKeywordsAsync(SearchParams); else await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); } public async Task LoanGetSearchResultSummary(long id) { var obj = await LoanGetAsync(id, false); var SearchParams = new Search.SearchIndexProcessObjectParameters(); if (obj != null) SearchParams.AddText(obj.Notes); return SearchParams; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task LoanPopulateVizFields(QuoteItemLoan o, bool calculateTotalsOnly = false) { if (calculateTotalsOnly == false) { if (loanUnitRateEnumList == null) loanUnitRateEnumList = await AyaNova.Api.Controllers.EnumListController.GetEnumList( StringUtil.TrimTypeName(typeof(LoanUnitRateUnit).ToString()), UserTranslationId, CurrentUserRoles); o.UnitOfMeasureViz = loanUnitRateEnumList.Where(x => x.Id == (long)o.Rate).Select(x => x.Name).First(); } if (!vc.Has("loanunit", o.LoanUnitId)) vc.Add(await ct.LoanUnit.AsNoTracking().Where(x => x.Id == o.LoanUnitId).Select(x => x.Name).FirstOrDefaultAsync(), "loanunit", o.LoanUnitId); o.LoanUnitViz = vc.Get("loanunit", o.LoanUnitId); TaxCode Tax = null; if (o.TaxCodeId != null) { if (!oc.Has("tax", o.TaxCodeId)) { Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == o.TaxCodeId); oc.Add(Tax, "tax", o.TaxCodeId); } else Tax = (TaxCode)oc.Get("tax", o.TaxCodeId); } if (Tax != null) o.TaxCodeViz = Tax.Name; //manual price overrides anything o.PriceViz = o.ListPrice; if (o.PriceOverride != null) o.PriceViz = (decimal)o.PriceOverride; //Currently not contract discounted so no further calcs need apply to priceViz //Calculate totals and taxes //NET o.NetViz = MoneyUtil.Round(o.PriceViz * o.Quantity); //TAX o.TaxAViz = 0; o.TaxBViz = 0; if (Tax != null) { if (Tax.TaxAPct != 0) { o.TaxAViz = MoneyUtil.Round(o.NetViz * (Tax.TaxAPct / 100)); } if (Tax.TaxBPct != 0) { if (Tax.TaxOnTax) { o.TaxBViz = MoneyUtil.Round((o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100)); } else { o.TaxBViz = MoneyUtil.Round(o.NetViz * (Tax.TaxBPct / 100)); } } } o.LineTotalViz = o.NetViz + o.TaxAViz + o.TaxBViz; //RESTRICTED COST FIELD?? if (!UserCanViewLoanerCosts) o.Cost = 0;//cost already used in calcs and will not be updated on any update operation so this ensures the cost isn't sent over the wire } private List loanUnitRateEnumList = null; //////////////////////////////////////////////////////////////////////////////////////////////// //BIZ ACTIONS // // private async Task LoanBizActionsAsync(AyaEvent ayaEvent, QuoteItemLoan newObj, QuoteItemLoan oldObj, IDbContextTransaction transaction) { //automatic actions on record change, called AFTER validation //currently no processing required except for created or modified at this time if (ayaEvent != AyaEvent.Created && ayaEvent != AyaEvent.Modified) return; //SNAPSHOT PRICING bool SnapshotPricing = true; //if modifed, see what has changed and should be re-applied if (ayaEvent == AyaEvent.Modified) { //If it wasn't a complete part change there is no need to set pricing if (newObj.LoanUnitId == oldObj.LoanUnitId && newObj.Rate == oldObj.Rate) { SnapshotPricing = false; //maintain old cost as it can come from the client as zero when it shouldn't be or someone using the api and setting it directly //but we will only allow the price *we* set at the server initially newObj.Cost = oldObj.Cost; } } //Pricing if (SnapshotPricing) { //default in case nothing to apply newObj.Cost = 0; newObj.ListPrice = 0; LoanUnit loanUnit = await ct.LoanUnit.AsNoTracking().FirstOrDefaultAsync(x => x.Id == newObj.LoanUnitId); if (loanUnit != null) { switch (newObj.Rate) { case LoanUnitRateUnit.None: break; case LoanUnitRateUnit.Hours: newObj.Cost = loanUnit.RateHourCost; newObj.ListPrice = loanUnit.RateHour; break; case LoanUnitRateUnit.HalfDays: newObj.Cost = loanUnit.RateHalfDayCost; newObj.ListPrice = loanUnit.RateHalfDay; break; case LoanUnitRateUnit.Days: newObj.Cost = loanUnit.RateDayCost; newObj.ListPrice = loanUnit.RateDay; break; case LoanUnitRateUnit.Weeks: newObj.Cost = loanUnit.RateWeekCost; newObj.ListPrice = loanUnit.RateWeek; break; case LoanUnitRateUnit.Months: newObj.Cost = loanUnit.RateMonthCost; newObj.ListPrice = loanUnit.RateMonth; break; case LoanUnitRateUnit.Years: newObj.Cost = loanUnit.RateYearCost; newObj.ListPrice = loanUnit.RateYear; break; } } } } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task LoanValidateAsync(QuoteItemLoan proposedObj, QuoteItemLoan currentObj) { //skip validation if seeding // if(ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; //run validation and biz rules bool isNew = currentObj == null; if (UserIsRestrictedType) { //no edits allowed AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } if (proposedObj.QuoteItemId == 0) { AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); return;//this is a completely disqualifying error } else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) { AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); return;//this is a completely disqualifying error } //Check state if updatable right now if (!isNew) { //Front end is coded to save the state first before any other updates if it has changed and it would not be //a part of this header update so it's safe to check it here as it will be most up to date var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); if (CurrentWoStatus.Locked) { AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); return;//this is a completely disqualifying error } } //case 4171 removed // if (proposedObj.LoanUnitId < 1 || !await ct.LoanUnit.AnyAsync(x => x.Id == proposedObj.LoanUnitId)) // AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "LoanUnitId"); // if (proposedObj.Quantity < 0)//negative quantities are not allowed // AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "Quantity"); //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.Quote.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);//note: this is passed only to add errors //validate custom fields } } private void LoanValidateCanDelete(QuoteItemLoan obj) { if (UserIsRestrictedType) { //no edits allowed AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } if (obj == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return; } //re-check rights here necessary due to traversal delete from Principle object if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemLoan)) { AddError(ApiErrorCode.NOT_AUTHORIZED); return; } } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task LoanHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; QuoteItemLoan oProposed = (QuoteItemLoan)proposedObj; var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); //for notification purposes because has no name / tags field itself oProposed.Name = WorkorderInfo.Serial.ToString(); oProposed.Tags = WorkorderInfo.Tags; //STANDARD EVENTS FOR ALL OBJECTS await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); //SPECIFIC EVENTS FOR THIS OBJECT }//end of process notifications #endregion work order item LOAN level /* ██████╗ ██╗ ██╗████████╗███████╗██╗██████╗ ███████╗ ███████╗███████╗██████╗ ██╗ ██╗██╗ ██████╗███████╗ ██╔═══██╗██║ ██║╚══██╔══╝██╔════╝██║██╔══██╗██╔════╝ ██╔════╝██╔════╝██╔══██╗██║ ██║██║██╔════╝██╔════╝ ██║ ██║██║ ██║ ██║ ███████╗██║██║ ██║█████╗ ███████╗█████╗ ██████╔╝██║ ██║██║██║ █████╗ ██║ ██║██║ ██║ ██║ ╚════██║██║██║ ██║██╔══╝ ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║██║ ██╔══╝ ╚██████╔╝╚██████╔╝ ██║ ███████║██║██████╔╝███████╗ ███████║███████╗██║ ██║ ╚████╔╝ ██║╚██████╗███████╗ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝╚═════╝ ╚══════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═════╝╚══════╝ */ #region QuoteItemOutsideService level //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task OutsideServiceExistsAsync(long id) { return await ct.QuoteItemOutsideService.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task OutsideServiceCreateAsync(QuoteItemOutsideService newObject) { await OutsideServiceValidateAsync(newObject, null); if (HasErrors) return null; else { await ct.QuoteItemOutsideService.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); await OutsideServiceSearchIndexAsync(newObject, true); await OutsideServicePopulateVizFields(newObject); await OutsideServiceHandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// // GET // internal async Task OutsideServiceGetAsync(long id, bool logTheGetEvent = true) { if (UserIsSubContractorRestricted || UserIsSubContractorFull) //no access allowed at all return null; var ret = await ct.QuoteItemOutsideService.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, AyaType.QuoteItemOutsideService, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task OutsideServicePutAsync(QuoteItemOutsideService putObject) { QuoteItemOutsideService dbObject = await OutsideServiceGetAsync(putObject.Id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } if (dbObject.Concurrency != putObject.Concurrency) { AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await OutsideServiceValidateAsync(putObject, dbObject); if (HasErrors) return null; ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await OutsideServiceExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, putObject.Id, putObject.AyaType, AyaEvent.Modified), ct); await OutsideServiceSearchIndexAsync(putObject, false); await OutsideServicePopulateVizFields(putObject); await OutsideServiceHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task OutsideServiceDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); var dbObject = await OutsideServiceGetAsync(id, false); OutsideServiceValidateCanDelete(dbObject); if (HasErrors) return false; ct.QuoteItemOutsideService.Remove(dbObject); await ct.SaveChangesAsync(); //Log event await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "quoteitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); if (parentTransaction == null) await transaction.CommitAsync(); await OutsideServiceHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); return true; } ////////////////////////////////////////////// //INDEXING // private async Task OutsideServiceSearchIndexAsync(QuoteItemOutsideService obj, bool isNew) { //SEARCH INDEXING var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); SearchParams.AddText(obj.Notes).AddText(obj.RMANumber).AddText(obj.TrackingNumber); if (isNew) await Search.ProcessNewObjectKeywordsAsync(SearchParams); else await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); } public async Task OutsideServiceGetSearchResultSummary(long id) { var obj = await OutsideServiceGetAsync(id, false); var SearchParams = new Search.SearchIndexProcessObjectParameters(); if (obj != null) SearchParams.AddText(obj.Notes).AddText(obj.RMANumber).AddText(obj.TrackingNumber); return SearchParams; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task OutsideServicePopulateVizFields(QuoteItemOutsideService o, bool calculateTotalsOnly = false) { if (calculateTotalsOnly == false) { if (o.UnitId != 0) { if (!vc.Has("unitserial", o.UnitId)) vc.Add(await ct.Unit.AsNoTracking().Where(x => x.Id == o.UnitId).Select(x => x.Serial).FirstOrDefaultAsync(), "unitserial", o.UnitId); o.UnitViz = vc.Get("unitserial", o.UnitId); } if (o.VendorSentToId != null) { if (!vc.Has("vendorname", o.VendorSentToId)) vc.Add(await ct.Vendor.AsNoTracking().Where(x => x.Id == o.VendorSentToId).Select(x => x.Name).FirstOrDefaultAsync(), "vendorname", o.VendorSentToId); o.VendorSentToViz = vc.Get("vendorname", o.VendorSentToId); } if (o.VendorSentViaId != null) { if (!vc.Has("vendorname", o.VendorSentViaId)) vc.Add(await ct.Vendor.AsNoTracking().Where(x => x.Id == o.VendorSentViaId).Select(x => x.Name).FirstOrDefaultAsync(), "vendorname", o.VendorSentViaId); o.VendorSentViaViz = vc.Get("vendorname", o.VendorSentViaId); } } TaxCode Tax = null; if (o.TaxCodeId != null) { if (!oc.Has("tax", o.TaxCodeId)) { Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == o.TaxCodeId); oc.Add(Tax, "tax", o.TaxCodeId); } else Tax = (TaxCode)oc.Get("tax", o.TaxCodeId); } if (Tax != null) o.TaxCodeViz = Tax.Name; o.CostViz = o.ShippingCost + o.RepairCost; o.PriceViz = o.ShippingPrice + o.RepairPrice; //Currently not contract discounted so no further calcs need apply to priceViz //Calculate totals and taxes //NET o.NetViz = o.PriceViz;//just for standardization, no quantity so is redundant but reporting easier if all the same //TAX o.TaxAViz = 0; o.TaxBViz = 0; if (Tax != null) { if (Tax.TaxAPct != 0) { o.TaxAViz = MoneyUtil.Round(o.NetViz * (Tax.TaxAPct / 100)); } if (Tax.TaxBPct != 0) { if (Tax.TaxOnTax) { o.TaxBViz = MoneyUtil.Round((o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100)); } else { o.TaxBViz = MoneyUtil.Round(o.NetViz * (Tax.TaxBPct / 100)); } } } o.LineTotalViz = o.NetViz + o.TaxAViz + o.TaxBViz; } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task OutsideServiceValidateAsync(QuoteItemOutsideService proposedObj, QuoteItemOutsideService currentObj) { //skip validation if seeding // if(ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; //run validation and biz rules bool isNew = currentObj == null; if (UserIsRestrictedType) { //no edits allowed AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } if (proposedObj.QuoteItemId == 0) { AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); return;//this is a completely disqualifying error } else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) { AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); return;//this is a completely disqualifying error } //Check state if updatable right now if (!isNew) { //Front end is coded to save the state first before any other updates if it has changed and it would not be //a part of this header update so it's safe to check it here as it will be most up to date var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); if (CurrentWoStatus.Locked) { AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); return;//this is a completely disqualifying error } } if (proposedObj.UnitId < 1 || !await ct.Unit.AnyAsync(x => x.Id == proposedObj.UnitId)) AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "UnitId"); //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.Quote.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);//note: this is passed only to add errors //validate custom fields } } private void OutsideServiceValidateCanDelete(QuoteItemOutsideService obj) { if (UserIsRestrictedType) { //no edits allowed AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } if (obj == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return; } //re-check rights here necessary due to traversal delete from Principle object if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemOutsideService)) { AddError(ApiErrorCode.NOT_AUTHORIZED); return; } } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task OutsideServiceHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { /* OutsideServiceOverdue = 16,//* Workorder object , WorkOrderItemOutsideService created / updated, sets advance notice on due date tag filterable OutsideServiceReceived = 17,//* Workorder object , WorkOrderItemOutsideService updated, instant notification when item received, tag filterable */ ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; QuoteItemOutsideService oProposed = (QuoteItemOutsideService)proposedObj; var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); //for notification purposes because has no name / tags field itself oProposed.Name = WorkorderInfo.Serial.ToString(); oProposed.Tags = WorkorderInfo.Tags; //STANDARD EVENTS FOR ALL OBJECTS await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); //SPECIFIC EVENTS FOR THIS OBJECT }//end of process notifications #endregion work order item OUTSIDE SERVICE level /* ██████╗ █████╗ ██████╗ ████████╗███████╗ ██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██╔════╝ ██████╔╝███████║██████╔╝ ██║ ███████╗ ██╔═══╝ ██╔══██║██╔══██╗ ██║ ╚════██║ ██║ ██║ ██║██║ ██║ ██║ ███████║ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝ */ #region QuoteItemPart level //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task PartExistsAsync(long id) { return await ct.QuoteItemPart.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task CreatePartAsync(QuoteItemPart newObject) { using (var transaction = await ct.Database.BeginTransactionAsync()) { await PartValidateAsync(newObject, null); if (HasErrors) return null; else { await PartBizActionsAsync(AyaEvent.Created, newObject, null, null); await ct.QuoteItemPart.AddAsync(newObject); await ct.SaveChangesAsync(); if (HasErrors) { await transaction.RollbackAsync(); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); await PartSearchIndexAsync(newObject, true); await transaction.CommitAsync(); await PartPopulateVizFields(newObject); await PartHandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } } //////////////////////////////////////////////////////////////////////////////////////////////// // GET // internal async Task PartGetAsync(long id, bool logTheGetEvent = true) { if (UserIsSubContractorRestricted) //no access allowed at all return null; var ret = await ct.QuoteItemPart.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task PartPutAsync(QuoteItemPart putObject) { using (var transaction = await ct.Database.BeginTransactionAsync()) { QuoteItemPart dbObject = await PartGetAsync(putObject.Id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } if (dbObject.Concurrency != putObject.Concurrency) { AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await PartValidateAsync(putObject, dbObject); if (HasErrors) return null; await PartBizActionsAsync(AyaEvent.Modified, putObject, dbObject, null); ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); if (HasErrors) { await transaction.RollbackAsync(); return null; } } catch (DbUpdateConcurrencyException) { if (!await PartExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, putObject.Id, putObject.AyaType, AyaEvent.Modified), ct); await PartSearchIndexAsync(putObject, false); await transaction.CommitAsync(); await PartPopulateVizFields(putObject); await PartHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); return putObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task PartDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); var dbObject = await PartGetAsync(id, false); PartValidateCanDelete(dbObject); if (HasErrors) return false; await PartBizActionsAsync(AyaEvent.Deleted, null, dbObject, transaction); ct.QuoteItemPart.Remove(dbObject); await ct.SaveChangesAsync(); if (HasErrors) { await transaction.RollbackAsync(); return false; } //Log event await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "quoteitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); if (parentTransaction == null) await transaction.CommitAsync(); await PartHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); return true; } ////////////////////////////////////////////// //INDEXING // private async Task PartSearchIndexAsync(QuoteItemPart obj, bool isNew) { //SEARCH INDEXING var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); SearchParams.AddText(obj.Description).AddText(obj.Serials); if (isNew) await Search.ProcessNewObjectKeywordsAsync(SearchParams); else await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); } public async Task PartGetSearchResultSummary(long id) { var obj = await PartGetAsync(id, false); var SearchParams = new Search.SearchIndexProcessObjectParameters(); if (obj != null) SearchParams.AddText(obj.Description).AddText(obj.Serials); return SearchParams; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task PartPopulateVizFields(QuoteItemPart o, bool calculateTotalsOnly = false) { if (calculateTotalsOnly == false) { if (o.PartWarehouseId != 0) { if (!vc.Has("partwarehouse", o.PartWarehouseId)) vc.Add(await ct.PartWarehouse.AsNoTracking().Where(x => x.Id == o.PartWarehouseId).Select(x => x.Name).FirstOrDefaultAsync(), "partwarehouse", o.PartWarehouseId); o.PartWarehouseViz = vc.Get("partwarehouse", o.PartWarehouseId); } } Part part = null; if (o.PartId != 0) { if (!oc.Has("part", o.PartId)) { part = await ct.Part.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.PartId); oc.Add(part, "part", o.PartId); } else part = (Part)oc.Get("part", o.PartId); } else return;//this should never happen but this is insurance in case it does o.PartDescriptionViz = part.Description; o.PartNameViz = part.Name; o.UpcViz = part.UPC; TaxCode Tax = null; if (o.TaxPartSaleId != null) { if (!oc.Has("tax", o.TaxPartSaleId)) { Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == o.TaxPartSaleId); oc.Add(Tax, "tax", o.TaxPartSaleId); } else Tax = (TaxCode)oc.Get("tax", o.TaxPartSaleId); } if (Tax != null) o.TaxCodeViz = Tax.Name; o.PriceViz = 0; if (part != null) { //COST & PRICE NOT SET HERE, SET IN BIZACTIONS SNAPSHOTTED // o.CostViz = part.Cost; // o.ListPriceViz = part.Retail; o.UnitOfMeasureViz = part.UnitOfMeasure; o.PriceViz = o.ListPrice;//default price used if not manual or contract override } //manual price overrides anything if (o.PriceOverride != null) o.PriceViz = (decimal)o.PriceOverride; else { //not manual so could potentially have a contract adjustment var c = await GetCurrentQuoteContractFromRelatedAsync(AyaType.QuoteItem, o.QuoteItemId); if (c != null) { decimal pct = 0; ContractOverrideType cot = ContractOverrideType.PriceDiscount; bool TaggedAdjustmentInEffect = false; //POTENTIAL CONTRACT ADJUSTMENTS //First check if there is a matching tagged contract discount, that takes precedence if (c.ContractPartOverrideItems.Count > 0) { //Iterate all contract tagged items in order of ones with the most tags first foreach (var cp in c.ContractPartOverrideItems.OrderByDescending(z => z.Tags.Count)) if (cp.Tags.All(z => part.Tags.Any(x => x == z))) { if (cp.OverridePct != 0) { pct = cp.OverridePct / 100; cot = cp.OverrideType; TaggedAdjustmentInEffect = true; } } } //Generic discount? if (!TaggedAdjustmentInEffect && c.ServiceRatesOverridePct != 0) { pct = c.ServiceRatesOverridePct / 100; cot = c.ServiceRatesOverrideType; } //apply if discount found if (pct != 0) { if (cot == ContractOverrideType.CostMarkup) o.PriceViz = MoneyUtil.Round(o.Cost + (o.Cost * pct)); else if (cot == ContractOverrideType.PriceDiscount) o.PriceViz = MoneyUtil.Round(o.ListPrice - (o.ListPrice * pct)); } } } //Calculate totals and taxes //NET o.NetViz = MoneyUtil.Round(o.PriceViz * o.Quantity); //TAX o.TaxAViz = 0; o.TaxBViz = 0; if (Tax != null) { if (Tax.TaxAPct != 0) { o.TaxAViz = MoneyUtil.Round(o.NetViz * (Tax.TaxAPct / 100)); } if (Tax.TaxBPct != 0) { if (Tax.TaxOnTax) { o.TaxBViz = MoneyUtil.Round((o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100)); } else { o.TaxBViz = MoneyUtil.Round(o.NetViz * (Tax.TaxBPct / 100)); } } } o.LineTotalViz = o.NetViz + o.TaxAViz + o.TaxBViz; //RESTRICTED COST FIELD?? if (!UserCanViewPartCosts) o.Cost = 0;//cost already used in calcs and will not be updated on any update operation so this ensures the cost isn't sent over the wire } //////////////////////////////////////////////////////////////////////////////////////////////// //BIZ ACTIONS // // private async Task PartBizActionsAsync(AyaEvent ayaEvent, QuoteItemPart newObj, QuoteItemPart oldObj, IDbContextTransaction transaction) { //SNAPSHOT PRICING IF NECESSARY if (ayaEvent != AyaEvent.Created && ayaEvent != AyaEvent.Modified) return; //SNAPSHOT PRICING bool SnapshotPricing = true; //if modifed, see what has changed and should be re-applied if (ayaEvent == AyaEvent.Modified) { //If it wasn't a complete part change there is no need to set pricing if (newObj.PartId == oldObj.PartId) { SnapshotPricing = false; //maintain old cost as it can come from the client as zero when it shouldn't be or someone using the api and setting it directly //but we will only allow the price *we* set at the server initially newObj.Cost = oldObj.Cost; } } //Pricing if (SnapshotPricing) { //default in case nothing to apply newObj.Cost = 0; newObj.ListPrice = 0; var s = await ct.Part.AsNoTracking().FirstOrDefaultAsync(z => z.Id == newObj.PartId); if (s != null) { newObj.Cost = s.Cost; newObj.ListPrice = s.Retail; } } } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task PartValidateAsync(QuoteItemPart proposedObj, QuoteItemPart currentObj) { //skip validation if seeding // if(ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; //run validation and biz rules bool isNew = currentObj == null; if (UserIsRestrictedType) { //Parts: no edits allowed AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } if (proposedObj.QuoteItemId == 0) { AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); return;//this is a completely disqualifying error } else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) { AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); return;//this is a completely disqualifying error } //Check state if updatable right now if (!isNew) { //Front end is coded to save the state first before any other updates if it has changed and it would not be //a part of this header update so it's safe to check it here as it will be most up to date var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); if (CurrentWoStatus.Locked) { AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); return;//this is a completely disqualifying error } } if (!await BizObjectExistsInDatabase.ExistsAsync(AyaType.Part, proposedObj.PartId, ct)) { AddError(ApiErrorCode.NOT_FOUND, "PartId"); return; } //case 4171 removed // if (proposedObj.Quantity < 0)//negative quantities are not allowed // AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "Quantity"); //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.Quote.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);//note: this is passed only to add errors //validate custom fields } } private void PartValidateCanDelete(QuoteItemPart obj) { if (UserIsRestrictedType) { //Parts: no edits allowed AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } if (obj == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return; } //re-check rights here necessary due to traversal delete from Principle object if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemPart)) { AddError(ApiErrorCode.NOT_AUTHORIZED); return; } } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task PartHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; QuoteItemPart oProposed = (QuoteItemPart)proposedObj; var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); oProposed.Name = WorkorderInfo.Serial.ToString(); //for notification purposes because has no name / tags field itself oProposed.Tags = WorkorderInfo.Tags; //STANDARD EVENTS FOR ALL OBJECTS await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); //SPECIFIC EVENTS FOR THIS OBJECT }//end of process notifications #endregion work order item PARTS level /* ███████╗ ██████╗██╗ ██╗███████╗██████╗ ██╗ ██╗██╗ ███████╗██████╗ ██╗ ██╗███████╗███████╗██████╗ ███████╗ ██╔════╝██╔════╝██║ ██║██╔════╝██╔══██╗██║ ██║██║ ██╔════╝██╔══██╗ ██║ ██║██╔════╝██╔════╝██╔══██╗██╔════╝ ███████╗██║ ███████║█████╗ ██║ ██║██║ ██║██║ █████╗ ██║ ██║█████╗██║ ██║███████╗█████╗ ██████╔╝███████╗ ╚════██║██║ ██╔══██║██╔══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██║ ██║╚════╝██║ ██║╚════██║██╔══╝ ██╔══██╗╚════██║ ███████║╚██████╗██║ ██║███████╗██████╔╝╚██████╔╝███████╗███████╗██████╔╝ ╚██████╔╝███████║███████╗██║ ██║███████║ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝╚══════╝ */ #region QuoteItemScheduledUser level //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task ScheduledUserExistsAsync(long id) { return await ct.QuoteItemScheduledUser.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task ScheduledUserCreateAsync(QuoteItemScheduledUser newObject) { await ScheduledUserValidateAsync(newObject, null); if (HasErrors) return null; else { await ct.QuoteItemScheduledUser.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); await ScheduledUserPopulateVizFields(newObject); await ScheduledUserHandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// // GET // internal async Task ScheduledUserGetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.QuoteItemScheduledUser.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (UserIsRestrictedType && ret.UserId != UserId)//restricted users can only see their own return null; if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task ScheduledUserPutAsync(QuoteItemScheduledUser putObject) { QuoteItemScheduledUser dbObject = await ScheduledUserGetAsync(putObject.Id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } if (dbObject.Concurrency != putObject.Concurrency) { AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await ScheduledUserValidateAsync(putObject, dbObject); if (HasErrors) return null; ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await ScheduledUserExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, putObject.AyaType, AyaEvent.Modified), ct); await ScheduledUserPopulateVizFields(putObject); await ScheduledUserHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task ScheduledUserDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); var dbObject = await ScheduledUserGetAsync(id, false); ScheduledUserValidateCanDelete(dbObject); if (HasErrors) return false; ct.QuoteItemScheduledUser.Remove(dbObject); await ct.SaveChangesAsync(); //Log event await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "quoteitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); if (parentTransaction == null) await transaction.CommitAsync(); await ScheduledUserHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); return true; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task ScheduledUserPopulateVizFields(QuoteItemScheduledUser o) { if (o.UserId != null) { if (!vc.Has("user", o.UserId)) { vc.Add(await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(), "user", o.UserId); } o.UserViz = vc.Get("user", o.UserId); } if (o.ServiceRateId != null) { if (!vc.Has("servicerate", o.ServiceRateId)) { vc.Add(await ct.ServiceRate.AsNoTracking().Where(x => x.Id == o.ServiceRateId).Select(x => x.Name).FirstOrDefaultAsync(), "servicerate", o.ServiceRateId); } o.ServiceRateViz = vc.Get("servicerate", o.ServiceRateId); } } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task ScheduledUserValidateAsync(QuoteItemScheduledUser proposedObj, QuoteItemScheduledUser currentObj) { //skip validation if seeding // if(ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; //run validation and biz rules bool isNew = currentObj == null; if (proposedObj.QuoteItemId == 0) { AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); return;//this is a completely disqualifying error } else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) { AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); return;//this is a completely disqualifying error } if (UserIsRestrictedType) { //Scheduled Users: view only where they are the selected User and convert to labor record AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } //Check state if updatable right now if (!isNew) { //Front end is coded to save the state first before any other updates if it has changed and it would not be //a part of this header update so it's safe to check it here as it will be most up to date var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); if (CurrentWoStatus.Locked) { AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); return;//this is a completely disqualifying error } } //case 4171 removed // if (proposedObj.EstimatedQuantity < 0)//negative quantities are not allowed // AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "EstimatedQuantity"); //Start date AND end date must both be null or both contain values if (proposedObj.StartDate == null && proposedObj.StopDate != null) AddError(ApiErrorCode.VALIDATION_REQUIRED, "StopDate"); if (proposedObj.StartDate != null && proposedObj.StopDate == null) AddError(ApiErrorCode.VALIDATION_REQUIRED, "StopDate"); //Start date before end date if (proposedObj.StartDate != null && proposedObj.StopDate != null) if (proposedObj.StartDate > proposedObj.StopDate) AddError(ApiErrorCode.VALIDATION_STARTDATE_AFTER_ENDDATE, "StartDate"); //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.Quote.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);//note: this is passed only to add errors //validate custom fields } } private void ScheduledUserValidateCanDelete(QuoteItemScheduledUser obj) { if (obj == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return; } if (UserIsRestrictedType) { //Scheduled Users: view only where they are the selected User and convert to labor record AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } //re-check rights here necessary due to traversal delete from Principle object if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemScheduledUser)) { AddError(ApiErrorCode.NOT_AUTHORIZED); return; } } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task ScheduledUserHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; QuoteItemScheduledUser oProposed = (QuoteItemScheduledUser)proposedObj; var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); oProposed.Name = WorkorderInfo.Serial.ToString(); //for notification purposes because has no name field itself oProposed.Tags = WorkorderInfo.Tags; //for notification purposes because has no tag field itself //STANDARD EVENTS FOR ALL OBJECTS await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); //SPECIFIC EVENTS FOR THIS OBJECT }//end of process notifications #endregion work order item SCHEDULED USER level /* ████████╗ █████╗ ███████╗██╗ ██╗ ╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝ ██║ ███████║███████╗█████╔╝ ██║ ██╔══██║╚════██║██╔═██╗ ██║ ██║ ██║███████║██║ ██╗ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ */ #region QuoteItemTask level //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task TaskExistsAsync(long id) { return await ct.QuoteItemTask.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task TaskCreateAsync(QuoteItemTask newObject) { await TaskValidateAsync(newObject, null); if (HasErrors) return null; else { await ct.QuoteItemTask.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); await TaskSearchIndexAsync(newObject, true); await TaskPopulateVizFields(newObject); await TaskHandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// // GET // internal async Task TaskGetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.QuoteItemTask.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task TaskPutAsync(QuoteItemTask putObject) { QuoteItemTask dbObject = await TaskGetAsync(putObject.Id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } if (dbObject.Concurrency != putObject.Concurrency) { AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await TaskValidateAsync(putObject, dbObject); if (HasErrors) return null; ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await TaskExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, putObject.AyaType, AyaEvent.Modified), ct); await TaskSearchIndexAsync(dbObject, false); await TaskPopulateVizFields(putObject); await TaskHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task TaskDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); var dbObject = await TaskGetAsync(id, false); TaskValidateCanDelete(dbObject); if (HasErrors) return false; ct.QuoteItemTask.Remove(dbObject); await ct.SaveChangesAsync(); //Log event await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "quoteitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); if (parentTransaction == null) await transaction.CommitAsync(); await TaskHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); return true; } ////////////////////////////////////////////// //INDEXING // private async Task TaskSearchIndexAsync(QuoteItemTask obj, bool isNew) { //SEARCH INDEXING var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); SearchParams.AddText(obj.Task);//some are manually entered so this is worthwhile for that at least, also I guess predefined tasks that are more rare if (isNew) await Search.ProcessNewObjectKeywordsAsync(SearchParams); else await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); } public async Task TaskGetSearchResultSummary(long id) { var obj = await TaskGetAsync(id, false); var SearchParams = new Search.SearchIndexProcessObjectParameters(); if (obj != null) SearchParams.AddText(obj.Task); return SearchParams; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task TaskPopulateVizFields(QuoteItemTask o) { if (o.CompletedByUserId != null) { if (!vc.Has("user", o.CompletedByUserId)) { vc.Add(await ct.User.AsNoTracking().Where(x => x.Id == o.CompletedByUserId).Select(x => x.Name).FirstOrDefaultAsync(), "user", o.CompletedByUserId); } o.CompletedByUserViz = vc.Get("user", o.CompletedByUserId); } if (taskCompletionTypeEnumList == null) taskCompletionTypeEnumList = await AyaNova.Api.Controllers.EnumListController.GetEnumList( StringUtil.TrimTypeName(typeof(WorkOrderItemTaskCompletionType).ToString()), UserTranslationId, CurrentUserRoles); o.StatusViz = taskCompletionTypeEnumList.Where(x => x.Id == (long)o.Status).Select(x => x.Name).First(); } private List taskCompletionTypeEnumList = null; //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task TaskValidateAsync(QuoteItemTask proposedObj, QuoteItemTask currentObj) { //skip validation if seeding // if(ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; //run validation and biz rules bool isNew = currentObj == null; if (proposedObj.QuoteItemId == 0) { AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); return;//this is a completely disqualifying error } else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) { AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); return;//this is a completely disqualifying error } //Check state if updatable right now if (!isNew) { //Front end is coded to save the state first before any other updates if it has changed and it would not be //a part of this header update so it's safe to check it here as it will be most up to date var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); if (CurrentWoStatus.Locked) { AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); return;//this is a completely disqualifying error } } if (isNew && UserIsRestrictedType) { //restricted users are not allowed to make new task entries only fill them out AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } if (!isNew && UserIsRestrictedType) { //Existing record so just make sure they haven't changed the not changeable fields from the db version //* Tasks: view and edit existing tasks, set completion type and date only, no add or remove or changing other fields //note that UI will prevent this, this rule is only backup for 3rd party api users if (currentObj.Task != proposedObj.Task) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "Task"); if (currentObj.CompletedByUserId != UserId) AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "CompletedByUserId"); if (currentObj.Sequence != proposedObj.Sequence) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "Sequence"); //they *are* permitted to change the date and status but only if they are the selected user: //note that UI will prevent this, this rule is only backup for 3rd party api users if (currentObj.CompletedDate != proposedObj.CompletedDate && currentObj.CompletedByUserId != UserId) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "CompletedDate"); if (currentObj.Status != proposedObj.Status && currentObj.CompletedByUserId != UserId) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "Status"); } if (string.IsNullOrWhiteSpace(proposedObj.Task)) AddError(ApiErrorCode.VALIDATION_REQUIRED, "Task"); //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.Quote.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);//note: this is passed only to add errors //validate custom fields } } private void TaskValidateCanDelete(QuoteItemTask obj) { if (obj == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return; } if (UserIsRestrictedType) { //restricted users are not allowed to delete a task only fill them out AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } //re-check rights here necessary due to traversal delete from Principle object if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemTask)) { AddError(ApiErrorCode.NOT_AUTHORIZED); return; } } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task TaskHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; QuoteItemTask oProposed = (QuoteItemTask)proposedObj; var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); oProposed.Name = WorkorderInfo.Serial.ToString(); //for notification purposes because has no name / tags field itself oProposed.Tags = WorkorderInfo.Tags; //STANDARD EVENTS FOR ALL OBJECTS await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); //SPECIFIC EVENTS FOR THIS OBJECT }//end of process notifications #endregion work order item TASK level /* ████████╗██████╗ █████╗ ██╗ ██╗███████╗██╗ ╚══██╔══╝██╔══██╗██╔══██╗██║ ██║██╔════╝██║ ██║ ██████╔╝███████║██║ ██║█████╗ ██║ ██║ ██╔══██╗██╔══██║╚██╗ ██╔╝██╔══╝ ██║ ██║ ██║ ██║██║ ██║ ╚████╔╝ ███████╗███████╗ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚══════╝ */ #region QuoteItemTravel level //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task TravelExistsAsync(long id) { return await ct.QuoteItemTravel.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task TravelCreateAsync(QuoteItemTravel newObject) { await TravelValidateAsync(newObject, null); if (HasErrors) return null; else { await ct.QuoteItemTravel.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); await TravelSearchIndexAsync(newObject, true); await TravelPopulateVizFields(newObject); await TravelHandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// // GET // internal async Task TravelGetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.QuoteItemTravel.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (UserIsRestrictedType && ret.UserId != UserId) { AddError(ApiErrorCode.NOT_AUTHORIZED); return null; } if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task TravelPutAsync(QuoteItemTravel putObject) { QuoteItemTravel dbObject = await TravelGetAsync(putObject.Id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } if (dbObject.Concurrency != putObject.Concurrency) { AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await TravelValidateAsync(putObject, dbObject); if (HasErrors) return null; ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await TravelExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, putObject.AyaType, AyaEvent.Modified), ct); await TravelSearchIndexAsync(putObject, false); await TravelPopulateVizFields(putObject); await TravelHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task TravelDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); var dbObject = await TravelGetAsync(id, false); TravelValidateCanDelete(dbObject); if (HasErrors) return false; ct.QuoteItemTravel.Remove(dbObject); await ct.SaveChangesAsync(); //Log event await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "quoteitem:" + dbObject.QuoteItemId.ToString(), ct); await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); if (parentTransaction == null) await transaction.CommitAsync(); await TravelHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); return true; } ////////////////////////////////////////////// //INDEXING // private async Task TravelSearchIndexAsync(QuoteItemTravel obj, bool isNew) { //SEARCH INDEXING var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); SearchParams.AddText(obj.TravelDetails); if (isNew) await Search.ProcessNewObjectKeywordsAsync(SearchParams); else await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); } public async Task TravelGetSearchResultSummary(long id) { var obj = await TravelGetAsync(id, false); var SearchParams = new Search.SearchIndexProcessObjectParameters(); if (obj != null) SearchParams.AddText(obj.TravelDetails); return SearchParams; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task TravelPopulateVizFields(QuoteItemTravel o, bool calculateTotalsOnly = false) { if (calculateTotalsOnly == false) { if (o.UserId != null) { if (!vc.Has("user", o.UserId)) { vc.Add(await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(), "user", o.UserId); } o.UserViz = vc.Get("user", o.UserId); } } TravelRate Rate = null; if (o.TravelRateId != null) { if (!oc.Has("travelrate", o.TravelRateId)) { Rate = await ct.TravelRate.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.TravelRateId); oc.Add(Rate, "travelrate", o.TravelRateId); } else Rate = (TravelRate)oc.Get("travelrate", o.TravelRateId); o.TravelRateViz = Rate.Name; } TaxCode Tax = null; if (o.TaxCodeSaleId != null) { if (!oc.Has("tax", o.TaxCodeSaleId)) { Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == o.TaxCodeSaleId); oc.Add(Tax, "tax", o.TaxCodeSaleId); } else Tax = (TaxCode)oc.Get("tax", o.TaxCodeSaleId); } if (Tax != null) o.TaxCodeViz = Tax.Name; o.PriceViz = 0; if (Rate != null) { o.CostViz = Rate.Cost; o.ListPriceViz = Rate.Charge; o.UnitOfMeasureViz = Rate.Unit; o.PriceViz = Rate.Charge;//default price used if not manual or contract override } //manual price overrides anything if (o.PriceOverride != null) o.PriceViz = (decimal)o.PriceOverride; else { //not manual so could potentially have a contract adjustment var c = await GetCurrentQuoteContractFromRelatedAsync(AyaType.QuoteItem, o.QuoteItemId); if (c != null) { decimal pct = 0; ContractOverrideType cot = ContractOverrideType.PriceDiscount; bool TaggedAdjustmentInEffect = false; //POTENTIAL CONTRACT ADJUSTMENTS //First check if there is a matching tagged Travel rate contract discount, that takes precedence if (c.ContractTravelRateOverrideItems.Count > 0 && Rate != null) { //Iterate all contract tagged items in order of ones with the most tags first foreach (var csr in c.ContractTravelRateOverrideItems.OrderByDescending(z => z.Tags.Count)) if (csr.Tags.All(z => Rate.Tags.Any(x => x == z))) { if (csr.OverridePct != 0) { pct = csr.OverridePct / 100; cot = csr.OverrideType; TaggedAdjustmentInEffect = true; } } } //Generic discount? if (!TaggedAdjustmentInEffect && c.TravelRatesOverridePct != 0) { pct = c.TravelRatesOverridePct / 100; cot = c.TravelRatesOverrideType; } //apply if discount found if (pct != 0) { if (cot == ContractOverrideType.CostMarkup) o.PriceViz = MoneyUtil.Round(o.CostViz + (o.CostViz * pct)); else if (cot == ContractOverrideType.PriceDiscount) o.PriceViz = MoneyUtil.Round(o.ListPriceViz - (o.ListPriceViz * pct)); } } } //Calculate totals and taxes //NET o.NetViz = MoneyUtil.Round(o.PriceViz * o.TravelRateQuantity); //TAX o.TaxAViz = 0; o.TaxBViz = 0; if (Tax != null) { if (Tax.TaxAPct != 0) { o.TaxAViz = MoneyUtil.Round(o.NetViz * (Tax.TaxAPct / 100)); } if (Tax.TaxBPct != 0) { if (Tax.TaxOnTax) { o.TaxBViz = MoneyUtil.Round((o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100)); } else { o.TaxBViz = MoneyUtil.Round(o.NetViz * (Tax.TaxBPct / 100)); } } } o.LineTotalViz = o.NetViz + o.TaxAViz + o.TaxBViz; //RESTRICTIONS ON COST VISIBILITY? if (!UserCanViewLaborOrTravelRateCosts) { o.CostViz = 0; } } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task TravelValidateAsync(QuoteItemTravel proposedObj, QuoteItemTravel currentObj) { //skip validation if seeding // if(ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; //run validation and biz rules bool isNew = currentObj == null; if (proposedObj.QuoteItemId == 0) { AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); return;//this is a completely disqualifying error } else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) { AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); return;//this is a completely disqualifying error } //Check state if updatable right now if (!isNew) { //Front end is coded to save the state first before any other updates if it has changed and it would not be //a part of this header update so it's safe to check it here as it will be most up to date var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); if (CurrentWoStatus.Locked) { AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); return;//this is a completely disqualifying error } } if (UserIsRestrictedType && (proposedObj.UserId != UserId || (!isNew && currentObj.UserId != UserId))) { //no edits allowed on other people's records AddError(ApiErrorCode.NOT_AUTHORIZED); return; } //case 4171 removed // if (proposedObj.TravelRateQuantity < 0)//negative quantities are not allowed // AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "TravelRateQuantity"); // if (proposedObj.NoChargeQuantity < 0)//negative quantities are not allowed // AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "NoChargeQuantity"); //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.Quote.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);//note: this is passed only to add errors //validate custom fields } } private void TravelValidateCanDelete(QuoteItemTravel obj) { if (obj == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return; } if (UserIsRestrictedType) { //Travels: add (no user selection defaults to themselves), remove, view and edit only when they are the selected User if (obj.UserId != UserId) AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } //re-check rights here necessary due to traversal delete from Principle object if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemTravel)) { AddError(ApiErrorCode.NOT_AUTHORIZED); return; } } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task TravelHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; QuoteItemTravel oProposed = (QuoteItemTravel)proposedObj; var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); oProposed.Name = WorkorderInfo.Serial.ToString();//for notification purposes because has no name / tags field itself oProposed.Tags = WorkorderInfo.Tags; //STANDARD EVENTS FOR ALL OBJECTS await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); //SPECIFIC EVENTS FOR THIS OBJECT }//end of process notifications #endregion work order item TRAVEL level /* ██╗ ██╗███╗ ██╗██╗████████╗ ██║ ██║████╗ ██║██║╚══██╔══╝ ██║ ██║██╔██╗ ██║██║ ██║ ██║ ██║██║╚██╗██║██║ ██║ ╚██████╔╝██║ ╚████║██║ ██║ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝ */ #region QuoteItemUnit level //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task UnitExistsAsync(long id) { return await ct.QuoteItemUnit.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task UnitCreateAsync(QuoteItemUnit newObject) { await UnitValidateAsync(newObject, null); if (HasErrors) return null; else { newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); await ct.QuoteItemUnit.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); await UnitSearchIndexAsync(newObject, true); await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); await UnitPopulateVizFields(newObject, false); await UnitHandlePotentialNotificationEvent(AyaEvent.Created, newObject); return newObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// // GET // internal async Task UnitGetAsync(long id, bool logTheGetEvent = true) { if (UserIsSubContractorRestricted) //no access allowed at all return null; var ret = await ct.QuoteItemUnit.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task UnitPutAsync(QuoteItemUnit putObject) { QuoteItemUnit dbObject = await UnitGetAsync(putObject.Id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } if (dbObject.Concurrency != putObject.Concurrency) { AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } dbObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); dbObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); await UnitValidateAsync(putObject, dbObject); if (HasErrors) return null; ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await UnitExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, putObject.AyaType, AyaEvent.Modified), ct); await UnitSearchIndexAsync(putObject, false); await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags); await UnitPopulateVizFields(putObject, false); await UnitHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task UnitDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); var dbObject = await UnitGetAsync(id, false); UnitValidateCanDelete(dbObject); if (HasErrors) return false; ct.QuoteItemUnit.Remove(dbObject); await ct.SaveChangesAsync(); //Log event await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "quoteitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); if (parentTransaction == null) await transaction.CommitAsync(); await UnitHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); return true; } ////////////////////////////////////////////// //INDEXING // private async Task UnitSearchIndexAsync(QuoteItemUnit obj, bool isNew) { //SEARCH INDEXING var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); SearchParams.AddText(obj.Notes).AddText(obj.Wiki).AddText(obj.Tags).AddCustomFields(obj.CustomFields); if (isNew) await Search.ProcessNewObjectKeywordsAsync(SearchParams); else await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); } public async Task UnitGetSearchResultSummary(long id) { var obj = await UnitGetAsync(id, false); var SearchParams = new Search.SearchIndexProcessObjectParameters(); if (obj != null) SearchParams.AddText(obj.Notes).AddText(obj.Wiki).AddText(obj.Tags).AddCustomFields(obj.CustomFields); return SearchParams; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task UnitPopulateVizFields(QuoteItemUnit o, bool populateForReporting) { //see if it's in the cache already, populate the cache fully if not bool UnitHasModel = false; if (!vc.Has("unitserial", o.UnitId)) { //cache it var unitInfo = await ct.Unit.AsNoTracking() .Where(x => x.Id == o.UnitId) .Select(x => new { x.Serial, x.Description, x.UnitModelId, x.Address, x.City, x.Region, x.Country, x.Latitude, x.Longitude, x.Metered }) .FirstOrDefaultAsync(); vc.Add(unitInfo.Serial, "unitserial", o.UnitId); vc.Add(unitInfo.Description, "unitdesc", o.UnitId); vc.Add(unitInfo.Address, "unitaddr", o.UnitId); vc.Add(unitInfo.City, "unitcity", o.UnitId); vc.Add(unitInfo.Region, "unitregion", o.UnitId); vc.Add(unitInfo.Country, "unitcountry", o.UnitId); vc.Add(unitInfo.Latitude.ToString(), "unitlat", o.UnitId); vc.Add(unitInfo.Longitude.ToString(), "unitlong", o.UnitId); vc.Add(unitInfo.Metered.ToString(), "unitmetered", o.UnitId); if (unitInfo.UnitModelId != null) { UnitHasModel = true; //units model name cached? (if it is then the rest will be cached as well) if (!vc.Has("unitsmodelname", o.UnitId)) { //nope, model name cached?? if (!vc.Has("unitmodelname", unitInfo.UnitModelId)) { //nope, so cache it all var unitModelInfo = await ct.UnitModel.AsNoTracking().Where(x => x.Id == unitInfo.UnitModelId).Select(x => new { x.Name, x.VendorId }).FirstOrDefaultAsync(); vc.Add(unitModelInfo.Name, "unitmodelname", unitInfo.UnitModelId); vc.Add(unitModelInfo.Name, "unitsmodelname", o.UnitId); if (unitModelInfo.VendorId != null) { var ModelVendorName = vc.Get("unitsmodelvendorname", o.UnitId); if (ModelVendorName == null) { ModelVendorName = vc.Get("vendorname", unitModelInfo.VendorId); if (ModelVendorName == null) { ModelVendorName = await ct.Vendor.AsNoTracking().Where(x => x.Id == unitModelInfo.VendorId).Select(x => x.Name).FirstOrDefaultAsync(); vc.Add(ModelVendorName, "vendorname", unitModelInfo.VendorId); vc.Add(ModelVendorName, "unitsmodelvendorname", o.UnitId); } else { //cached under vendor so reuse here vc.Add(ModelVendorName, "unitsmodelvendorname", o.UnitId); } } } } } } } //populate all fields from cache if (UnitHasModel) { o.UnitModelNameViz = vc.Get("unitsmodelname", o.UnitId); o.UnitModelVendorViz = vc.Get("unitsmodelvendorname", o.UnitId); } o.UnitViz = vc.Get("unitserial", o.UnitId); o.UnitDescriptionViz = vc.Get("unitdesc", o.UnitId); // o.UnitMeteredViz = vc.GetAsBool("unitmetered", o.UnitId); if (populateForReporting) { o.AddressViz = vc.Get("unitaddr", o.UnitId); o.CityViz = vc.Get("unitcity", o.UnitId); o.RegionViz = vc.Get("unitregion", o.UnitId); o.CountryViz = vc.Get("unitcountry", o.UnitId); o.LatitudeViz = vc.GetAsDecimal("unitlat", o.UnitId); o.LongitudeViz = vc.GetAsDecimal("unitlong", o.UnitId); } } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task UnitValidateAsync(QuoteItemUnit proposedObj, QuoteItemUnit currentObj) { //skip validation if seeding // if(ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; //run validation and biz rules bool isNew = currentObj == null; if (UserIsRestrictedType) { //Units: no edits allowed AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } if (proposedObj.QuoteItemId == 0) { AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); return;//this is a completely disqualifying error } else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) { AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); return;//this is a completely disqualifying error } //Check state if updatable right now if (!isNew) { //Front end is coded to save the state first before any other updates if it has changed and it would not be //a part of this header update so it's safe to check it here as it will be most up to date var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); if (CurrentWoStatus.Locked) { AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); return;//this is a completely disqualifying error } } if (proposedObj.UnitId < 1 || !await ct.Unit.AnyAsync(x => x.Id == proposedObj.UnitId)) AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "UnitId"); //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.Quote.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);//note: this is passed only to add errors //validate custom fields CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields,"WorkOrderItemUnitCustom"); } } private void UnitValidateCanDelete(QuoteItemUnit obj) { if (UserIsRestrictedType) { //Units: no edits allowed AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } if (obj == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return; } //re-check rights here necessary due to traversal delete from Principle object if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemUnit)) { AddError(ApiErrorCode.NOT_AUTHORIZED); return; } } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task UnitHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; QuoteItemUnit oProposed = (QuoteItemUnit)proposedObj; var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); oProposed.Name = WorkorderInfo.Serial.ToString();//for notification purposes because has no name field itself //STANDARD EVENTS FOR ALL OBJECTS await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); //SPECIFIC EVENTS FOR THIS OBJECT }//end of process notifications #endregion work order item LABOR level #region Utility public async Task GetQuoteGraphItem(AyaType ayaType, long id) { switch (ayaType) { case AyaType.Quote: return await QuoteGetAsync(id, false) as ICoreBizObjectModel; case AyaType.QuoteItem: return await ItemGetAsync(id, false); case AyaType.QuoteItemExpense: return await ExpenseGetAsync(id, false); case AyaType.QuoteItemLabor: return await LaborGetAsync(id, false); case AyaType.QuoteItemLoan: return await LoanGetAsync(id, false); case AyaType.QuoteItemPart: return await PartGetAsync(id, false); case AyaType.QuoteItemScheduledUser: return await ScheduledUserGetAsync(id, false); case AyaType.QuoteItemTask: return await TaskGetAsync(id, false); case AyaType.QuoteItemTravel: return await TravelGetAsync(id, false); case AyaType.QuoteItemUnit: return await UnitGetAsync(id, false); case AyaType.QuoteItemOutsideService: return await OutsideServiceGetAsync(id, false); default: throw new System.ArgumentOutOfRangeException($"Quote::GetQuoteGraphItem -> Invalid ayaType{ayaType}"); } } public async Task PutQuoteGraphItem(AyaType ayaType, ICoreBizObjectModel o) { ClearErrors(); switch (ayaType) { case AyaType.Quote: if (o is Quote) { Quote dto = new Quote(); CopyObject.Copy(o, dto, "Name"); return await QuotePutAsync((Quote)dto); } return await QuotePutAsync((Quote)o) as ICoreBizObjectModel; case AyaType.QuoteItem: if (o is QuoteItem) { QuoteItem dto = new QuoteItem(); CopyObject.Copy(o, dto); return await ItemPutAsync((QuoteItem)dto); } return await ItemPutAsync((QuoteItem)o); case AyaType.QuoteItemExpense: return await ExpensePutAsync((QuoteItemExpense)o); case AyaType.QuoteItemLabor: return await LaborPutAsync((QuoteItemLabor)o); case AyaType.QuoteItemLoan: return await LoanPutAsync((QuoteItemLoan)o); case AyaType.QuoteItemPart: return await PartPutAsync((QuoteItemPart)o); case AyaType.QuoteItemScheduledUser: return await ScheduledUserPutAsync((QuoteItemScheduledUser)o); case AyaType.QuoteItemTask: return await TaskPutAsync((QuoteItemTask)o); case AyaType.QuoteItemTravel: return await TravelPutAsync((QuoteItemTravel)o); case AyaType.QuoteItemUnit: return await UnitPutAsync((QuoteItemUnit)o); case AyaType.QuoteItemOutsideService: return await OutsideServicePutAsync((QuoteItemOutsideService)o); default: throw new System.ArgumentOutOfRangeException($"Quote::PutQuoteGraphItem -> Invalid ayaType{ayaType}"); } } public async Task DeleteQuoteGraphItem(AyaType ayaType, long id) { switch (ayaType) { case AyaType.Quote: return await QuoteDeleteAsync(id); case AyaType.QuoteItem: return await ItemDeleteAsync(id); case AyaType.QuoteItemExpense: return await ExpenseDeleteAsync(id); case AyaType.QuoteItemLabor: return await LaborDeleteAsync(id); case AyaType.QuoteItemLoan: return await LoanDeleteAsync(id); case AyaType.QuoteItemPart: return await PartDeleteAsync(id); case AyaType.QuoteItemScheduledUser: return await ScheduledUserDeleteAsync(id); case AyaType.QuoteItemTask: return await TaskDeleteAsync(id); case AyaType.QuoteItemTravel: return await TravelDeleteAsync(id); case AyaType.QuoteItemUnit: return await UnitDeleteAsync(id); case AyaType.QuoteItemOutsideService: return await OutsideServiceDeleteAsync(id); default: throw new System.ArgumentOutOfRangeException($"Quote::GetQuoteGraphItem -> Invalid ayaType{ayaType}"); } } //////////////////////////////////////////////////////////////////////////////////////////////// //GET CONTRACT FOR WORKORDER FROM RELATIVE // //cache the contract to save repeatedly fetching it for this operation internal Contract mContractInEffect = null; internal bool mFetchedContractAlready = false;//null contract isn't enough to know it was fetched as it could just not have a contract so this is required internal async Task GetCurrentQuoteContractFromRelatedAsync(AyaType ayaType, long id) { if (mFetchedContractAlready == false) { var wid = await GetQuoteIdFromRelativeAsync(ayaType, id, ct); var WoContractId = await ct.Quote.AsNoTracking().Where(z => z.Id == wid.ParentId).Select(z => z.ContractId).FirstOrDefaultAsync(); await GetCurrentContractFromContractIdAsync(WoContractId); } return mContractInEffect; } internal async Task GetCurrentContractFromContractIdAsync(long? id) { if (id == null) return null; if (mFetchedContractAlready == false) { mContractInEffect = await GetFullyPopulatedContractGraphFromIdAsync(id); } return mContractInEffect; } internal async Task GetFullyPopulatedContractGraphFromIdAsync(long? id) { if (id == null) return null; return await ct.Contract.AsSplitQuery().AsNoTracking() .Include(c => c.ServiceRateItems) .Include(c => c.TravelRateItems) .Include(c => c.ContractPartOverrideItems) .Include(c => c.ContractTravelRateOverrideItems) .Include(c => c.ContractServiceRateOverrideItems) .FirstOrDefaultAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //GET CURRENT STATUS FOR WORKORDER FROM RELATIVE // //cache the state to save repeatedly fetching it for this operation which could be called multiple times in a flowv internal QuoteStatus mCurrentQuoteStatus = null; internal async Task GetCurrentQuoteStatusFromRelatedAsync(AyaType ayaType, long id) { if (mCurrentQuoteStatus == null) { var wid = await GetQuoteIdFromRelativeAsync(ayaType, id, ct); var stat = await ct.QuoteState.AsNoTracking() .Where(z => z.QuoteId == wid.ParentId) .OrderByDescending(z => z.Id)//case 4224 .Take(1) .FirstOrDefaultAsync(); //no state set yet? if (stat == null) mCurrentQuoteStatus = new QuoteStatus() { Id = -1, Locked = false, Completed = false }; else mCurrentQuoteStatus = await ct.QuoteStatus.AsNoTracking().Where(z => z.Id == stat.QuoteStatusId).FirstAsync();//this should never not be null } return mCurrentQuoteStatus; } #endregion utility ///////////////////////////////////////////////////////////////////// }//eoc }//eons