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; 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 } /* ██████╗ ██╗ ██╗ ██████╗ ████████╗███████╗ ██╔═══██╗██║ ██║██╔═══██╗╚══██╔══╝██╔════╝ ██║ ██║██║ ██║██║ ██║ ██║ █████╗ ██║▄▄ ██║██║ ██║██║ ██║ ██║ ██╔══╝ ╚██████╔╝╚██████╔╝╚██████╔╝ ██║ ███████╗ ╚══▀▀═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ */ #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), 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) .Include(w => w.Items.OrderBy(item => item.Sequence)) .ThenInclude(wi => wi.Expenses) .Include(w => w.Items) .ThenInclude(wi => wi.Labors) .Include(w => w.Items) .ThenInclude(wi => wi.Loans) .Include(w => w.Items) .ThenInclude(wi => wi.Parts) .Include(w => w.Items) .ThenInclude(wi => wi.ScheduledUsers) .Include(w => w.Items) .ThenInclude(wi => wi.Tasks.OrderBy(t => t.Sequence)) .Include(w => w.Items) .ThenInclude(wi => wi.Travels) .Include(w => w.Items) .ThenInclude(wi => wi.Units) .Include(w => w.Items) .ThenInclude(wi => wi.OutsideServices) .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);//doing this here ahead of notification because notification may require the viz field lookup anyway and afaict no harm in it await QuoteHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task QuoteDeleteAsync(long id) { using (var transaction = await ct.Database.BeginTransactionAsync()) { try { 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; //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); } catch { //NOTE: no need to rollback the transaction, it will auto-rollback if not committed and it is disposed when it goes out of scope either way //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } 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 (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.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) { 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; } public void DigestSearchText(Quote obj, Search.SearchIndexProcessObjectParameters searchParams) { if (obj != null) searchParams.AddText(obj.Notes).AddText(obj.Serial).AddText(obj.Wiki).AddText(obj.Tags).AddCustomFields(obj.CustomFields); } //////////////////////////////////////////////////////////////////////////////////////////////// // "The Andy" notification helper // // (for now this is only for the notification exceeds total so only need one grand total of // line totals, if in future need more can return a Record object instead with split out // taxes, net etc etc) // private async Task WorkorderGrandTotalAsync(long workOrderId, AyContext ct) { var wo = await ct.Quote.AsNoTracking().AsSplitQuery() .Include(w => w.Items.OrderBy(item => item.Sequence)) .ThenInclude(wi => wi.Expenses) .Include(w => w.Items) .ThenInclude(wi => wi.Labors) .Include(w => w.Items) .ThenInclude(wi => wi.Loans) .Include(w => w.Items) .ThenInclude(wi => wi.Parts) .Include(w => w.Items) .ThenInclude(wi => wi.Travels) .Include(w => w.Items) .ThenInclude(wi => wi.OutsideServices) .SingleOrDefaultAsync(z => z.Id == workOrderId); if (wo == null) return 0m; decimal GrandTotal = 0m; //update pricing foreach (QuoteItem wi in wo.Items) { foreach (QuoteItemExpense o in wi.Expenses) await ExpensePopulateVizFields(o, true); foreach (QuoteItemLabor o in wi.Labors) await LaborPopulateVizFields(o, true); foreach (QuoteItemLoan o in wi.Loans) await LoanPopulateVizFields(o, null, true); foreach (QuoteItemPart o in wi.Parts) await PartPopulateVizFields(o, true); foreach (QuoteItemTravel o in wi.Travels) await TravelPopulateVizFields(o, true); foreach (QuoteItemOutsideService o in wi.OutsideServices) await OutsideServicePopulateVizFields(o, true); } foreach (QuoteItem wi in wo.Items) { foreach (QuoteItemExpense o in wi.Expenses) GrandTotal += o.LineTotalViz; foreach (QuoteItemLabor o in wi.Labors) GrandTotal += o.LineTotalViz; foreach (QuoteItemLoan o in wi.Loans) GrandTotal += o.LineTotalViz; foreach (QuoteItemPart o in wi.Parts) GrandTotal += o.LineTotalViz; foreach (QuoteItemTravel o in wi.Travels) GrandTotal += o.LineTotalViz; foreach (QuoteItemOutsideService o in wi.OutsideServices) GrandTotal += o.LineTotalViz; } return GrandTotal; } //////////////////////////////////////////////////////////////////////////////////////////////// //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) 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().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) { //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(); while (idList.Any()) { var batch = idList.Take(IReportAbleObject.REPORT_DATA_BATCH_SIZE); idList = idList.Skip(IReportAbleObject.REPORT_DATA_BATCH_SIZE).ToArray(); List batchResults = new List(); foreach (long batchId in batch) batchResults.Add(await QuoteGetPartialAsync(dataListSelectedRequest.AType, batchId, dataListSelectedRequest.IncludeWoItemDescendants, true)); foreach (Quote w in batchResults) { 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); } } 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); } //popup Alert notes //Customer notes first then others below var custInfo = await ct.Customer.AsNoTracking().Where(x => x.Id == o.CustomerId).Select(x => new { AlertViz = x.PopUpNotes, x.TechNotes, CustomerViz = x.Name }).FirstOrDefaultAsync(); if (!string.IsNullOrWhiteSpace(custInfo.AlertViz)) { o.AlertViz = $"{await Translate("Customer")} - {await Translate("AlertNotes")}\n{custInfo.AlertViz}\n\n"; } if (!string.IsNullOrWhiteSpace(custInfo.TechNotes)) { o.CustomerTechNotesViz = $"{await Translate("CustomerTechNotes")}\n{custInfo.TechNotes}\n\n"; } o.CustomerViz = custInfo.CustomerViz; if (o.ProjectId != null) o.ProjectViz = await ct.Project.AsNoTracking().Where(x => x.Id == o.ProjectId).Select(x => x.Name).FirstOrDefaultAsync(); if (o.PreparedById != null) o.PreparedByViz = await ct.User.AsNoTracking().Where(x => x.Id == o.PreparedById).Select(x => x.Name).FirstOrDefaultAsync(); if (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(); o.ContractViz = contractVizFields.Name; if (!string.IsNullOrWhiteSpace(contractVizFields.AlertNotes)) { o.AlertViz += $"{await Translate("Contract")}\n{contractVizFields.AlertNotes}\n\n"; } } else o.ContractViz = "-"; } //////////////////////////////////////////////////////////////////////////////////////////////// // IMPORT EXPORT // public async Task GetExportData(DataListSelectedRequest dataListSelectedRequest) { //for now just re-use the report data code //this may turn out to be the pattern for most biz object types but keeping it seperate allows for custom usage from time to time return await GetReportData(dataListSelectedRequest); } // public async Task> ImportData(JArray ja) // { // List ImportResult = new List(); // string ImportTag = $"imported-{FileUtil.GetSafeDateFileName()}"; // var jsset = JsonSerializer.CreateDefault(new JsonSerializerSettings { ContractResolver = new AyaNova.Util.JsonUtil.ShouldSerializeContractResolver(new string[] { "Concurrency", "Id", "CustomFields" }) }); // foreach (JObject j in ja) // { // var w = j.ToObject(jsset); // if (j["CustomFields"] != null) // w.CustomFields = j["CustomFields"].ToString(); // w.Tags.Add(ImportTag);//so user can find them all and revert later if necessary // var res = await QuoteCreateAsync(w); // if (res == null) // { // ImportResult.Add($"* {w.Serial} - {this.GetErrorsAsString()}"); // this.ClearErrors(); // } // else // { // ImportResult.Add($"{w.Serial} - ok"); // } // } // return ImportResult; // } //////////////////////////////////////////////////////////////////////////////////////////////// //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.Widget.Select(z => z.Id).ToListAsync(); bool SaveIt = false; foreach (long id in idList) { try { 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++; } } } catch (Exception ex) { await JobsBiz.LogJobAsync(job.GId, $"LT:Errors id({id})"); await JobsBiz.LogJobAsync(job.GId, ExceptionUtil.ExtractAllExceptionMessages(ex)); } } await JobsBiz.LogJobAsync(job.GId, $"LT:BatchJob {job.SubType} {idList.Count}{(FailedObjectCount > 0 ? " - LT:Failed " + FailedObjectCount : "")}"); await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Completed); } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task QuoteHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{this.BizType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; 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 // Quote oCurrent = null; // bool SameTags = true; // if (currentObj != null) // { // oCurrent = (Quote)currentObj; // SameTags = NotifyEventHelper.TwoObjectsHaveSameTags(proposedObj.Tags, currentObj.Tags); // } }//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) { await StateValidateAsync(newObject, null); if (HasErrors) return null; else { await ct.QuoteState.AddAsync(newObject); var qoute = await ct.Quote.FirstOrDefaultAsync(x => x.Id == newObject.QuoteId); var newStatusInfo = await ct.QuoteStatus.AsNoTracking().FirstOrDefaultAsync(x => x.Id == newObject.QuoteStatusId); qoute.LastStatusId = newObject.QuoteStatusId; await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, AyaType.QuoteStatus, AyaEvent.Created), ct); 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) { o.UserViz = await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(); // if (o.QuoteOverseerId != null) // o.QuoteOverseerViz = await ct.User.AsNoTracking().Where(x => x.Id == o.QuoteOverseerId).Select(x => x.Name).FirstOrDefaultAsync(); } //////////////////////////////////////////////////////////////////////////////////////////////// //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) { try { 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); } } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } return true; } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task StateValidateAsync(QuoteState proposedObj, QuoteState currentObj) { //of all restricted users, only a restricted tech can change status if (UserIsSubContractorFull || UserIsSubContractorRestricted) { AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); return; } //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"); } } //////////////////////////////////////////////////////////////////////////////////////////////// // NOTIFICATION PROCESSING // public async Task StateHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) { ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); if (ServerBootConfig.SEEDING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); bool isNew = currentObj == null; //currently no quote state notifications but may well be in future so this saves changing a bunch of shit if necessary later await Task.CompletedTask; // QuoteState oProposed = (QuoteState)proposedObj; // var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == oProposed.QuoteId).Select(x => new { x.Serial, x.Tags, x.CustomerId }).FirstOrDefaultAsync(); // QuoteStatus wos = await ct.QuoteStatus.AsNoTracking().FirstOrDefaultAsync(x => x.Id == oProposed.QuoteStatusId); // //for notification purposes because has no name / tags field itself // oProposed.Name = WorkorderInfo.Serial.ToString(); // oProposed.Tags = WorkorderInfo.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 // //WorkorderStatusChange = 4,//*Workorder object, any NEW status set. Conditions: specific status ID value only (no generic any status allowed), Workorder TAGS // //WorkorderCompletedStatusOverdue = 15,//* Workorder object not set to a "Completed" flagged quote status type in selected time span from creation of workorderWorkorderSetToCompletedStatus // //WorkorderStatusAge = 24,//* Workorder 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 // //NOTE: ID, state notifications are for the Workorder, not the state itself unlike other objects, so use the WO 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 // // await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, o.Id, NotifyEventType.WorkorderStatusChange); // // await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, o.Id, NotifyEventType.WorkorderCompletedStatusOverdue); // // await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, o.Id, NotifyEventType.WorkorderStatusAge); // //## 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) // { // //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.WorkorderStatusChange && 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(WorkorderInfo.Tags, sub.Tags)) // { // NotifyEvent n = new NotifyEvent() // { // EventType = NotifyEventType.WorkorderStatusChange, // UserId = sub.UserId, // AyaType = AyaType.Quote, // ObjectId = oProposed.QuoteId, // NotifySubscriptionId = sub.Id, // Name = $"{WorkorderInfo.Serial.ToString()} - {wos.Name}" // }; // await ct.NotifyEvent.AddAsync(n); // log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); // await ct.SaveChangesAsync(); // } // } // }//quote status change event // //# STATUS AGE // { // //WorkorderStatusAge = 24,//* Workorder 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, proposedObj.AyaType, proposedObj.Id, NotifyEventType.WorkorderStatusAge); // var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.WorkorderStatusAge && 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(WorkorderInfo.Tags, sub.Tags)) // { // NotifyEvent n = new NotifyEvent() // { // EventType = NotifyEventType.WorkorderStatusAge, // UserId = sub.UserId, // AyaType = AyaType.Quote, // ObjectId = oProposed.QuoteId, // NotifySubscriptionId = sub.Id, // Name = $"{WorkorderInfo.Serial.ToString()} - {wos.Name}" // }; // await ct.NotifyEvent.AddAsync(n); // log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); // await ct.SaveChangesAsync(); // } // } // }//quote status change event // //# COMPLETE BY OVERDUE // { // //NOTE: the initial notification is created by the Workorder Header notification as it's where this time delayed notification is first generated // //the only job here in state notification is to remove any prior finish overdue notifications waiting if a new state is selected that is a completed state // //NOTE ABOUT RE-OPEN DECISION ON HOW THIS WORKS: // //what though if it's not a Completed status, then I guess don't remove it, but what if it *was* a Completed status and it's change to a non Completed? // //that, in essence re-opens it so it's not Completed at that point. // //My decision on this june 2021 is that a work order Completed status notification is satisifed the moment it's saved with a Completed status // //and nothing afterwards restarts that process so if a person sets closed status then sets open status again no new Completed overdue notification will be generated // if (wos.Completed) // { // //Workorder was just set to a completed status so remove any notify events lurking to deliver for overdue // await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, oProposed.QuoteId, NotifyEventType.WorkorderCompletedStatusOverdue); // } // }//quote complete by overdue change event // //# WorkorderCompleted - Customer AND User but customer only notifies if it's their quote // { // if (wos.Completed) // { // //look for potential subscribers // var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.WorkorderCompleted).ToListAsync(); // foreach (var sub in subs) // { // //not for inactive users // if (!await UserBiz.UserIsActive(sub.UserId)) continue; // //Customer User? // var UserInfo = await ct.User.AsNoTracking().Where(x => x.Id == sub.UserId).Select(x => new { x.CustomerId, x.UserType, x.HeadOfficeId }).FirstOrDefaultAsync(); // if (UserInfo.UserType == UserType.Customer || UserInfo.UserType == UserType.HeadOffice) // { // //CUSTOMER USER // //Quick short circuit: if quote doesn't have a customer id then it's not going to match no matter what // if (WorkorderInfo.CustomerId == 0) continue; // var customerUserRights = await UserBiz.CustomerUserEffectiveRightsAsync(sub.UserId); // //Are they allowed right now to use this type of notification? // if (!customerUserRights.NotifyWOCompleted) continue; // //is this their related work order? // if (UserInfo.CustomerId != WorkorderInfo.CustomerId) // { // //not the same customer but might be a head office user and this is one of their customers so check for that // if (UserInfo.HeadOfficeId == null) continue;//can't match any head office so no need to go further // //see if quote customer's head office is the same id as the user's headofficeid (note that a customer user with the same head office as a *different* customer quote doesn't qualify) // var CustomerInfo = await ct.Customer.AsNoTracking().Where(x => x.Id == WorkorderInfo.CustomerId).Select(x => new { x.HeadOfficeId, x.BillHeadOffice }).FirstOrDefaultAsync(); // if (!CustomerInfo.BillHeadOffice) continue;//can't possibly match so no need to go further // if (UserInfo.HeadOfficeId != CustomerInfo.HeadOfficeId) continue; // } // } // else // { // //INSIDE USER // //Tag match? (will be true if no sub tags so always safe to call this) // //check early to avoid cost of fetching and calculating total if unnecessary // if (!NotifyEventHelper.ObjectHasAllSubscriptionTags(WorkorderInfo.Tags, sub.Tags)) continue; // } // //Ok, we're here so it must be ok to notify user // NotifyEvent n = new NotifyEvent() // { // EventType = NotifyEventType.WorkorderCompleted, // UserId = sub.UserId, // AyaType = AyaType.Quote, // ObjectId = oProposed.QuoteId, // NotifySubscriptionId = sub.Id, // Name = $"{WorkorderInfo.Serial.ToString()}" // }; // await ct.NotifyEvent.AddAsync(n); // log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); // await ct.SaveChangesAsync(); // } // } // }//WorkorderCompleted // } }//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 ItemHandlePotentialNotificationEvent(AyaEvent.Created, newObject); await ItemPopulateVizFields(newObject, false); 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 ItemHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); await ItemPopulateVizFields(putObject, false); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task ItemDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); try { 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); } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } 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.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.Wiki).AddText(obj.Tags).AddCustomFields(obj.CustomFields); return SearchParams; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task ItemPopulateVizFields(QuoteItem o, bool populateForReporting) { // if (o.QuoteOverseerId != null) // o.QuoteOverseerViz = await ct.User.AsNoTracking().Where(x => x.Id == o.QuoteOverseerId).Select(x => x.Name).FirstOrDefaultAsync(); 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"); } //summary is required now, this is a change from v7 //I did this because it is required in terms of hiding on the form so it also //is required to have a value. This is really because the form field customization I took away the hideable field //maybe I should add that feature back? if (proposedObj.QuoteId == 0) AddError(ApiErrorCode.VALIDATION_REQUIRED, "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))//negative quantities are not allowed AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "Notes"); //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItem.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 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) 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 //## DELETED EVENTS //any event added below needs to be removed, so //just blanket remove any event for this object of eventtype that would be added below here //do it regardless any time there's an update and then //let this code below handle the refreshing addition that could have changes // await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, o.Id, NotifyEventType.ContractExpiring); //## CREATED / MODIFIED EVENTS if (ayaEvent == AyaEvent.Created || ayaEvent == AyaEvent.Modified) { //todo: fix etc, tons of shit here incoming } }//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 TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); await ExpenseHandlePotentialNotificationEvent(AyaEvent.Created, newObject); await ExpensePopulateVizFields(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 ExpenseHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); await ExpensePopulateVizFields(putObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task ExpenseDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); try { 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, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); //await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); if (parentTransaction == null) await transaction.CommitAsync(); await ExpenseHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } return true; } ////////////////////////////////////////////// //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) o.UserViz = await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(); } TaxCode Tax = null; if (o.ChargeTaxCodeId != null) Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == 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 = o.ChargeAmount * (Tax.TaxAPct / 100); } if (Tax.TaxBPct != 0) { if (Tax.TaxOnTax) { o.TaxBViz = (o.ChargeAmount + o.TaxAViz) * (Tax.TaxBPct / 100); } else { o.TaxBViz = 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) 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.QuoteItemExpense.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) 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(); if (WorkorderInfo != null) oProposed.Name = WorkorderInfo.Serial.ToString(); else oProposed.Name = "??"; 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 LaborBizActionsAsync(AyaEvent.Created, newObject, null, null); //newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); 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 TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); await LaborHandlePotentialNotificationEvent(AyaEvent.Created, newObject); await LaborPopulateVizFields(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 TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags); await LaborHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); await LaborPopulateVizFields(putObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task LaborDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); try { 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, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); // await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct); if (parentTransaction == null) await transaction.CommitAsync(); await LaborHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } return true; } ////////////////////////////////////////////// //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) o.UserViz = await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(); } ServiceRate Rate = null; if (o.ServiceRateId != null) { Rate = await ct.ServiceRate.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.ServiceRateId); o.ServiceRateViz = Rate.Name; } TaxCode Tax = null; if (o.TaxCodeSaleId != null) Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == 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) { //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 = o.CostViz + (o.CostViz * pct); else if (cot == ContractOverrideType.PriceDiscount) o.PriceViz = o.ListPriceViz - (o.ListPriceViz * pct); } } } //Calculate totals and taxes //NET o.NetViz = o.PriceViz * o.ServiceRateQuantity; //TAX o.TaxAViz = 0; o.TaxBViz = 0; if (Tax != null) { if (Tax.TaxAPct != 0) { o.TaxAViz = o.NetViz * (Tax.TaxAPct / 100); } if (Tax.TaxBPct != 0) { if (Tax.TaxOnTax) { o.TaxBViz = (o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100); } else { o.TaxBViz = 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) 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"); 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.QuoteItemLabor.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 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) 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 TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); await LoanHandlePotentialNotificationEvent(AyaEvent.Created, newObject); await LoanPopulateVizFields(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 TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags); await LoanHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); await LoanPopulateVizFields(putObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task LoanDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); try { 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, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); //await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct); if (parentTransaction == null) await transaction.CommitAsync(); await LoanHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } return true; } ////////////////////////////////////////////// //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, List loanUnitRateEnumList = null, 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(); } LoanUnit loanUnit = await ct.LoanUnit.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.LoanUnitId); o.LoanUnitViz = loanUnit.Name; TaxCode Tax = null; if (o.TaxCodeId != null) Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == 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 = o.PriceViz * o.Quantity; //TAX o.TaxAViz = 0; o.TaxBViz = 0; if (Tax != null) { if (Tax.TaxAPct != 0) { o.TaxAViz = o.NetViz * (Tax.TaxAPct / 100); } if (Tax.TaxBPct != 0) { if (Tax.TaxOnTax) { o.TaxBViz = (o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100); } else { o.TaxBViz = 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 } //////////////////////////////////////////////////////////////////////////////////////////////// //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) 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.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.QuoteItemLoan.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 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) 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 { // newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); 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 TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); await OutsideServiceHandlePotentialNotificationEvent(AyaEvent.Created, newObject); await OutsideServicePopulateVizFields(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; } // dbObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); // dbObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); 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 TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, dbObject.Tags, SnapshotOfOriginalDBObj.Tags); await OutsideServiceHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); await OutsideServicePopulateVizFields(putObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task OutsideServiceDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); try { 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, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); //await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); if (parentTransaction == null) await transaction.CommitAsync(); await OutsideServiceHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } return true; } ////////////////////////////////////////////// //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) o.UnitViz = await ct.Unit.AsNoTracking().Where(x => x.Id == o.UnitId).Select(x => x.Serial).FirstOrDefaultAsync(); if (o.VendorSentToId != null) o.VendorSentToViz = await ct.Vendor.AsNoTracking().Where(x => x.Id == o.VendorSentToId).Select(x => x.Name).FirstOrDefaultAsync(); if (o.VendorSentViaId != null) o.VendorSentViaViz = await ct.Vendor.AsNoTracking().Where(x => x.Id == o.VendorSentViaId).Select(x => x.Name).FirstOrDefaultAsync(); } TaxCode Tax = null; if (o.TaxCodeId != null) Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == 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 = o.NetViz * (Tax.TaxAPct / 100); } if (Tax.TaxBPct != 0) { if (Tax.TaxOnTax) { o.TaxBViz = (o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100); } else { o.TaxBViz = 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) 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.QuoteItemOutsideService.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 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) 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 //## DELETED EVENTS //standard process above will remove any hanging around when deleted, nothing else specific here to deal with //## CREATED if (ayaEvent == AyaEvent.Created) { //OutsideServiceOverdue if (oProposed.ETADate != null) { //Conditions: tags + time delayed eta value var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.OutsideServiceOverdue).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(WorkorderInfo.Tags, sub.Tags)) { NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.OutsideServiceOverdue, UserId = sub.UserId, AyaType = AyaType.QuoteItemOutsideService, ObjectId = oProposed.Id, NotifySubscriptionId = sub.Id, EventDate = (DateTime)oProposed.ETADate, Name = $"{WorkorderInfo.Serial.ToString()}" }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); await ct.SaveChangesAsync(); } } }//OutsideServiceOverdue //OutsideServiceReceived (here because it's possible a outside service is entered new with both an eta and received date if entered after the fact) if (oProposed.ReturnDate != null) { //Clear overdue ones as it's now received await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, proposedObj.Id, NotifyEventType.OutsideServiceOverdue); //Conditions: tags var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.OutsideServiceReceived).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(WorkorderInfo.Tags, sub.Tags)) { NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.OutsideServiceReceived, UserId = sub.UserId, AyaType = AyaType.QuoteItemOutsideService, ObjectId = oProposed.Id, NotifySubscriptionId = sub.Id, Name = $"{WorkorderInfo.Serial.ToString()}" }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); await ct.SaveChangesAsync(); } } }//OutsideServiceReceived } //## MODIFIED if (ayaEvent == AyaEvent.Modified) { QuoteItemOutsideService oCurrent = (QuoteItemOutsideService)currentObj; //OutsideServiceOverdue //if modified then remove any potential prior ones in case irrelevant if (oProposed.ETADate != oCurrent.ETADate) { //eta changed, so first of all remove any prior ones await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, proposedObj.Id, NotifyEventType.OutsideServiceOverdue); //now can go ahead and add back again as appropriate if (oProposed.ETADate != null) { //Conditions: tags + time delayed eta value var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.OutsideServiceOverdue).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(WorkorderInfo.Tags, sub.Tags)) { NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.OutsideServiceOverdue, UserId = sub.UserId, AyaType = AyaType.QuoteItemOutsideService, ObjectId = oProposed.Id, NotifySubscriptionId = sub.Id, EventDate = (DateTime)oProposed.ETADate, Name = $"{WorkorderInfo.Serial.ToString()}" }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); await ct.SaveChangesAsync(); } } }//OutsideServiceOverdue } //OutsideServiceReceived if (oProposed.ReturnDate != oCurrent.ReturnDate && oProposed.ReturnDate != null)//note that this is an instant notification type so no need to clear older ones like above which is time delayed { //Clear overdue ones as it's now received await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, proposedObj.Id, NotifyEventType.OutsideServiceOverdue); //Conditions: tags var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.OutsideServiceReceived).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(WorkorderInfo.Tags, sub.Tags)) { NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.OutsideServiceReceived, UserId = sub.UserId, AyaType = AyaType.QuoteItemOutsideService, ObjectId = oProposed.Id, NotifySubscriptionId = sub.Id, Name = $"{WorkorderInfo.Serial.ToString()}" }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); await ct.SaveChangesAsync(); } } }//OutsideServiceReceived } }//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); //newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); 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 TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); await PartPopulateVizFields(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; } //dbObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); //dbObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); 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 TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, dbObject.Tags, SnapshotOfOriginalDBObj.Tags); await PartHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); await transaction.CommitAsync(); await PartPopulateVizFields(putObject); return putObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task PartDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); try { 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, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); //await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); if (parentTransaction == null) await transaction.CommitAsync(); await PartHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } return true; } ////////////////////////////////////////////// //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) o.PartWarehouseViz = await ct.PartWarehouse.AsNoTracking().Where(x => x.Id == o.PartWarehouseId).Select(x => x.Name).FirstOrDefaultAsync(); } Part part = null; if (o.PartId != 0) part = await ct.Part.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.PartId); else return;//this should never happen but this is insurance in case it does o.PartViz = part.PartNumber; o.PartNameViz = part.Name; o.UpcViz = part.UPC; TaxCode Tax = null; if (o.TaxPartSaleId != null) Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == 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 = o.Cost + (o.Cost * pct); else if (cot == ContractOverrideType.PriceDiscount) o.PriceViz = o.ListPrice - (o.ListPrice * pct); } } } //Calculate totals and taxes //NET o.NetViz = o.PriceViz * o.Quantity; //TAX o.TaxAViz = 0; o.TaxBViz = 0; if (Tax != null) { if (Tax.TaxAPct != 0) { o.TaxAViz = o.NetViz * (Tax.TaxAPct / 100); } if (Tax.TaxBPct != 0) { if (Tax.TaxOnTax) { o.TaxBViz = (o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100); } else { o.TaxBViz = 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) 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; } 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.QuoteItemPart.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 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) 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 { //newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); await ct.QuoteItemScheduledUser.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); //await ScheduledUserSearchIndexAsync(newObject, true); //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); await ScheduledUserHandlePotentialNotificationEvent(AyaEvent.Created, newObject); await ScheduledUserPopulateVizFields(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; } //dbObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); // dbObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); 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 ScheduledUserSearchIndexAsync(dbObject, false); //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, dbObject.Tags, SnapshotOfOriginalDBObj.Tags); await ScheduledUserHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); await ScheduledUserPopulateVizFields(putObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task ScheduledUserDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); try { 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, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); //await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); if (parentTransaction == null) await transaction.CommitAsync(); await ScheduledUserHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } return true; } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task ScheduledUserPopulateVizFields(QuoteItemScheduledUser o) { if (o.UserId != null) o.UserViz = await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(); if (o.ServiceRateId != null) o.ServiceRateViz = await ct.ServiceRate.AsNoTracking().Where(x => x.Id == o.ServiceRateId).Select(x => x.Name).FirstOrDefaultAsync(); } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task ScheduledUserValidateAsync(QuoteItemScheduledUser proposedObj, QuoteItemScheduledUser currentObj) { //skip validation if seeding // if (ServerBootConfig.SEEDING) 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 } } 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"); //Scheduling conflict? if (!AyaNova.Util.ServerGlobalBizSettings.Cache.AllowScheduleConflicts && proposedObj.UserId != null && proposedObj.StartDate != null && proposedObj.StopDate != null && (isNew || (proposedObj.StartDate != currentObj.StartDate) || (proposedObj.StopDate != currentObj.StopDate) || (proposedObj.UserId != currentObj.UserId) )) { if (await ct.QuoteItemScheduledUser.AnyAsync(x => x.Id != proposedObj.Id && x.UserId == proposedObj.UserId && x.StartDate <= proposedObj.StopDate && proposedObj.StartDate <= x.StopDate)) { AddError(ApiErrorCode.VALIDATION_FAILED, "StartDate", await Translate("ScheduleConflict")); AddError(ApiErrorCode.VALIDATION_FAILED, "StopDate", await Translate("ScheduleConflict")); } } //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItemScheduledUser.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 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) 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 //## CREATED / UPDATED - ScheduledOnWorkorder event //Note: scheduled on quote is immediate so same process regardless if modified or updated //because modified changes nearly all affect user so decision is just send it no matter what as any difference is enough to send if (ayaEvent == AyaEvent.Created || ayaEvent == AyaEvent.Modified) { //this block is entirely about //ScheduledOnWorkorder event if (oProposed.UserId != null) { //Conditions: userid match and tags //delivery is immediate so no need to remove old ones of this kind var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.ScheduledOnWorkorder && z.UserId == oProposed.UserId).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(WorkorderInfo.Tags, sub.Tags)) { NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.ScheduledOnWorkorder, UserId = sub.UserId, AyaType = AyaType.QuoteItemScheduledUser, ObjectId = oProposed.Id, NotifySubscriptionId = sub.Id, Name = $"{WorkorderInfo.Serial.ToString()}" }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); await ct.SaveChangesAsync(); } } }//ScheduledOnWorkorder } //--------------------------------------------------------------------------------------------------------------------------------------------- //## CREATED if (ayaEvent == AyaEvent.Created) { //ScheduledOnWorkorderImminent if (oProposed.UserId != null && oProposed.StartDate != null) { //Conditions: userid match and tags + time delayed age value //delivery is delayed so need to remove old ones of this kind on update var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.ScheduledOnWorkorderImminent && z.UserId == oProposed.UserId).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(WorkorderInfo.Tags, sub.Tags)) { NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.ScheduledOnWorkorderImminent, UserId = sub.UserId, AyaType = AyaType.QuoteItemScheduledUser, ObjectId = oProposed.Id, NotifySubscriptionId = sub.Id, EventDate = (DateTime)oProposed.StartDate, Name = $"{WorkorderInfo.Serial.ToString()}" }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); await ct.SaveChangesAsync(); } } }//ScheduledOnWorkorderImminent } //## MODIFIED if (ayaEvent == AyaEvent.Modified) { //ScheduledOnWorkorderImminent //Always clear any old ones for this object as they are all irrelevant the moment changed: await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, proposedObj.Id, NotifyEventType.ScheduledOnWorkorderImminent); if (oProposed.UserId != null && oProposed.StartDate != null) { //Conditions: userid match and tags + time delayed age value //delivery is delayed so need to remove old ones of this kind on update var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.ScheduledOnWorkorderImminent && z.UserId == oProposed.UserId).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(WorkorderInfo.Tags, sub.Tags)) { NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.ScheduledOnWorkorderImminent, UserId = sub.UserId, AyaType = AyaType.QuoteItemScheduledUser, ObjectId = oProposed.Id, NotifySubscriptionId = sub.Id, EventDate = (DateTime)oProposed.StartDate, Name = $"{WorkorderInfo.Serial.ToString()}" }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); await ct.SaveChangesAsync(); } } }//ScheduledOnWorkorderImminent } }//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 { //newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); 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 TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); await TaskHandlePotentialNotificationEvent(AyaEvent.Created, newObject); await TaskPopulateVizFields(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; } //dbObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); //dbObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); 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 TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, dbObject.Tags, SnapshotOfOriginalDBObj.Tags); await TaskHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); await TaskPopulateVizFields(putObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task TaskDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); try { 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, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); //await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); if (parentTransaction == null) await transaction.CommitAsync(); await TaskHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } return true; } ////////////////////////////////////////////// //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, List taskCompletionTypeEnumList = null) { if (o.CompletedByUserId != null) o.CompletedByUserViz = await ct.User.AsNoTracking().Where(x => x.Id == o.CompletedByUserId).Select(x => x.Name).FirstOrDefaultAsync(); 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(); } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task TaskValidateAsync(QuoteItemTask proposedObj, QuoteItemTask currentObj) { //skip validation if seeding // if (ServerBootConfig.SEEDING) 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"); } 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.QuoteItemTask.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 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) 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 TravelHandlePotentialNotificationEvent(AyaEvent.Created, newObject); await TravelPopulateVizFields(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 TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, dbObject.Tags, SnapshotOfOriginalDBObj.Tags); await TravelHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); await TravelPopulateVizFields(putObject); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task TravelDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); try { 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, "woitem:" + dbObject.QuoteItemId.ToString(), ct); await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); // await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); //await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct); if (parentTransaction == null) await transaction.CommitAsync(); await TravelHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } return true; } ////////////////////////////////////////////// //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) o.UserViz = await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(); } TravelRate Rate = null; if (o.TravelRateId != null) { Rate = await ct.TravelRate.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.TravelRateId); o.TravelRateViz = Rate.Name; } TaxCode Tax = null; if (o.TaxCodeSaleId != null) Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == 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) { //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 = o.CostViz + (o.CostViz * pct); else if (cot == ContractOverrideType.PriceDiscount) o.PriceViz = o.ListPriceViz - (o.ListPriceViz * pct); } } } //Calculate totals and taxes //NET o.NetViz = o.PriceViz * o.TravelRateQuantity; //TAX o.TaxAViz = 0; o.TaxBViz = 0; if (Tax != null) { if (Tax.TaxAPct != 0) { o.TaxAViz = o.NetViz * (Tax.TaxAPct / 100); } if (Tax.TaxBPct != 0) { if (Tax.TaxOnTax) { o.TaxBViz = (o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100); } else { o.TaxBViz = 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) 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; } 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.QuoteItemTravel.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 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) 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) { //todo: contract stuff and validation of no other existing contracted unit //assumptions: this create only gets called if there is an existing woheader saved in all cases await UnitValidateAsync(newObject, null); if (HasErrors) return null; else { //await UnitBizActionsAsync(AyaEvent.Created, newObject, null, null); 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 UnitHandlePotentialNotificationEvent(AyaEvent.Created, newObject); await UnitPopulateVizFields(newObject, false); 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 UnitHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); await UnitPopulateVizFields(putObject, false); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task UnitDeleteAsync(long id, IDbContextTransaction parentTransaction = null) { var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); try { 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, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); if (parentTransaction == null) await transaction.CommitAsync(); await UnitHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } return true; } ////////////////////////////////////////////// //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.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.Tags).AddCustomFields(obj.CustomFields); return SearchParams; } // //////////////////////////////////////////////////////////////////////////////////////////////// // //BIZ ACTIONS // // // // // private async Task UnitBizActionsAsync(AyaEvent ayaEvent, QuoteItemUnit newObj, QuoteItemUnit 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; // if (newOrChangedActiveUnitContract != null && (ayaEvent == AyaEvent.Modified || ayaEvent == AyaEvent.Created))//note: keeping this qualification defensively in case more biz actions added later // { // //set contract if applicable // //Note: validation has already set neworchangeactiveunitcontract and only sets it if it's applicable // //so in here we just need to apply that contract to the header // //I've decided that it will attempt to set the header here now rather than after the unit has set // //as it's more important to have the unit record be saved than to // // //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; // // } // } // } //////////////////////////////////////////////////////////////////////////////////////////////// //VIZ POPULATE // private async Task UnitPopulateVizFields(QuoteItemUnit o, bool populateForReporting) { 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 }) .FirstOrDefaultAsync(); o.UnitViz = unitInfo.Serial; o.UnitDescriptionViz = unitInfo.Description; if (populateForReporting) { o.AddressViz = unitInfo.Address; o.CityViz = unitInfo.City; o.RegionViz = unitInfo.Region; o.CountryViz = unitInfo.Country; o.LatitudeViz = unitInfo.Latitude; o.LongitudeViz = unitInfo.Longitude; } if (unitInfo.UnitModelId != null) { var unitModelInfo = await ct.UnitModel.AsNoTracking().Where(x => x.Id == unitInfo.UnitModelId).Select(x => new { x.Name, x.VendorId, x.Number }).FirstOrDefaultAsync(); o.UnitModelNameViz = unitModelInfo.Name; o.UnitModelModelNumberViz = unitModelInfo.Number; if (unitModelInfo.VendorId != null) o.UnitModelVendorViz = await ct.Vendor.AsNoTracking().Where(x => x.Id == unitModelInfo.VendorId).Select(x => x.Name).FirstOrDefaultAsync(); } } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task UnitValidateAsync(QuoteItemUnit proposedObj, QuoteItemUnit currentObj) { //skip validation if seeding // if (ServerBootConfig.SEEDING) return; // - A work order *MUST* have only one Unit with a Contract, if there is already a unit with a contract on this quote then a new one cannot be added and it will reject with a validation error // a unit record is saved only *after* there is already a header (by api users and our client software) so can easily check and set here //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"); // //Contracted unit? Only one per work order is allowed // if (isNew || proposedObj.UnitId != currentObj.UnitId) // { // bool AlreadyHasAContractedUnit = false; // //See if this unit has a contract, if so then see if the contract is active, if so then iterate quote graph and check all other units for same // //if any found then reject this // var proposedUnitInfo = await ct.Unit.AsNoTracking().Where(x => x.Id == proposedObj.UnitId).Select(x => new { x.ContractExpires, x.ContractId }).FirstOrDefaultAsync(); // if (proposedUnitInfo.ContractId != null && proposedUnitInfo.ContractExpires > DateTime.UtcNow) // { // //added woitemunit has a contract and apparently unexpired so need to check if contract is still active // newOrChangedActiveUnitContractId = proposedUnitInfo.ContractId; // if (await ct.Contract.AsNoTracking().Where(z => z.Id == proposedUnitInfo.ContractId).Select(x => x.Active).FirstOrDefaultAsync() == true) // { // //iterate work order and check for other contracted unit // var woId = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, proposedObj.QuoteItemId, ct); // newOrChangedActiveUnitQuoteId = woId.QuoteId;//save for later contract update if necessary // var w = await QuoteGetFullAsync(woId.QuoteId); // //iterate, look for *other* woitemunit records, are they contracted already? // foreach (QuoteItem wi in w.Items) // { // if (AlreadyHasAContractedUnit) continue; // foreach (QuoteItemUnit wiu in wi.Units) // { // if (isNew || wiu.Id != currentObj.Id) // { // var existingUnitInfo = await ct.Unit.AsNoTracking().Where(x => x.Id == wiu.UnitId).Select(x => new { x.ContractExpires, x.ContractId }).FirstOrDefaultAsync(); // if (existingUnitInfo != null) // { // if (existingUnitInfo.ContractId != null && existingUnitInfo.ContractExpires > DateTime.UtcNow) // { // //Ok, we have a pre-existing contract, is it active? // if (await ct.Contract.AsNoTracking().Where(x => x.Id == existingUnitInfo.ContractId).Select(x => x.Active).FirstOrDefaultAsync()) // { // AlreadyHasAContractedUnit = true; // continue; // } // } // } // } // } // } // if (AlreadyHasAContractedUnit) // { // AddError(ApiErrorCode.VALIDATION_WO_MULTIPLE_CONTRACTED_UNITS, "UnitId"); // return;//this is a completely disqualifying error // } // } // else // { // newOrChangedActiveUnitContractId = null;//just in case it's non active but present so later biz actions don't process it // } // } // } //Any form customizations to validate? var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItemUnit.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 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) 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); 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.Created) .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; } // internal static async Task GetCurrentQuoteStatusFromRelatedAsync(AyaType ayaType, long id, AyContext ct) // { // //static method // var wid = await GetQuoteIdFromRelativeAsync(ayaType, id, ct); // var stat = await ct.QuoteState.AsNoTracking() // .Where(z => z.QuoteId == wid.ParentId) // .OrderByDescending(z => z.Created) // .Take(1) // .FirstOrDefaultAsync(); // //no state set yet? // if (stat == null) // { //default // return new QuoteStatus() { Id = -1, Locked = false, Completed = false }; // } // return await ct.QuoteStatus.AsNoTracking().Where(z => z.Id == stat.QuoteStatusId).FirstAsync();//this should never not be null // } #endregion utility ///////////////////////////////////////////////////////////////////// }//eoc }//eons