From 6b90c2c7d490e81ddffbe337a57a9f83cb9fe5d0 Mon Sep 17 00:00:00 2001 From: John Cardinal Date: Tue, 27 Jul 2021 23:41:40 +0000 Subject: [PATCH] --- server/AyaNova/biz/BizObjectFactory.cs | 20 +- server/AyaNova/biz/PMBiz.cs | 5205 +++++++++++++++++++++++- server/AyaNova/models/PM.cs | 3 +- 3 files changed, 5045 insertions(+), 183 deletions(-) diff --git a/server/AyaNova/biz/BizObjectFactory.cs b/server/AyaNova/biz/BizObjectFactory.cs index 21a5c9cb..46370109 100644 --- a/server/AyaNova/biz/BizObjectFactory.cs +++ b/server/AyaNova/biz/BizObjectFactory.cs @@ -62,9 +62,7 @@ namespace AyaNova.Biz case AyaType.PartInventory: return new PartInventoryBiz(ct, userId, translationId, roles); - case AyaType.PM: - return new PMBiz(ct, userId, translationId, roles); - + case AyaType.Project: return new ProjectBiz(ct, userId, translationId, roles); case AyaType.PurchaseOrder: @@ -110,6 +108,22 @@ namespace AyaNova.Biz return new QuoteBiz(ct, userId, translationId, roles, UserType.NotService);//default to not service for now arbitrarily on the principle of least access //--- + + //--- PM + case AyaType.PM: + case AyaType.PMItem: + case AyaType.PMItemExpense: + case AyaType.PMItemLabor: + case AyaType.PMItemLoan: + case AyaType.PMItemPart: + case AyaType.PMItemScheduledUser: + case AyaType.PMItemTask: + case AyaType.PMItemTravel: + case AyaType.PMItemUnit: + case AyaType.PMItemOutsideService: + return new PMBiz(ct, userId, translationId, roles, UserType.NotService);//default to not service for now arbitrarily on the principle of least access + //--- + case AyaType.Reminder: return new ReminderBiz(ct, userId, translationId, roles); case AyaType.Review: diff --git a/server/AyaNova/biz/PMBiz.cs b/server/AyaNova/biz/PMBiz.cs index 76fc62c8..2c5eef5d 100644 --- a/server/AyaNova/biz/PMBiz.cs +++ b/server/AyaNova/biz/PMBiz.cs @@ -1,207 +1,567 @@ 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 PMBiz : BizObject, ISearchAbleObject - { - //Feature specific roles - internal static AuthorizationRoles RolesAllowedToChangeSerial = AuthorizationRoles.BizAdmin | AuthorizationRoles.Service | AuthorizationRoles.Accounting; - internal PMBiz(AyContext dbcontext, long currentUserId, long userTranslationId, AuthorizationRoles UserRoles) + internal class PMBiz : BizObject, IJobObject, ISearchAbleObject, IReportAbleObject, IExportAbleObject + { + internal PMBiz(AyContext dbcontext, long currentUserId, long userTranslationId, AuthorizationRoles UserRoles, UserType currentUserType) { ct = dbcontext; UserId = currentUserId; UserTranslationId = userTranslationId; CurrentUserRoles = UserRoles; BizType = AyaType.PM; + 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 PMBiz GetBiz(AyContext ct, Microsoft.AspNetCore.Http.HttpContext httpContext = null) { - if (httpContext != null) - return new PMBiz(ct, UserIdFromContext.Id(httpContext.Items), UserTranslationIdFromContext.Id(httpContext.Items), UserRolesFromContext.Roles(httpContext.Items)); - else//when called internally for internal ops there will be no context so need to set default values for that - return new PMBiz(ct, 1, ServerBootConfig.AYANOVA_DEFAULT_TRANSLATION_ID, AuthorizationRoles.BizAdmin); + return new PMBiz(ct, + UserIdFromContext.Id(httpContext.Items), + UserTranslationIdFromContext.Id(httpContext.Items), + UserRolesFromContext.Roles(httpContext.Items), + UserTypeFromContext.Type(httpContext.Items)); + else + return new PMBiz(ct, + 1, + ServerBootConfig.AYANOVA_DEFAULT_TRANSLATION_ID, + AuthorizationRoles.BizAdmin, + UserType.NotService);//picked not service arbitrarily, probably a non-factor } + /* + ██████╗ ███╗ ███╗ + ██╔══██╗████╗ ████║ + ██████╔╝██╔████╔██║ + ██╔═══╝ ██║╚██╔╝██║ + ██║ ██║ ╚═╝ ██║ + ╚═╝ ╚═╝ ╚═╝ + */ + + #region PM 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 ExistsAsync(long id) + internal async Task PMExistsAsync(long id) { return await ct.PM.AnyAsync(z => z.Id == id); } - //################################################################################################################################################### - //################################################################################################################################################### - // WARNING! THIS OBJECT IS AN INITIAL TEST VERSION NOT UP TO CURRENT STANDARDS, SEE WORKORDERBIZ FOR HOW THIS SHOULD BE CODED - //################################################################################################################################################### - //################################################################################################################################################### - - - //////////////////////////////////////////////////////////////////////////////////////////////// - /// GET - /// - /// - - internal async Task GetAsync(long fetchId, bool logTheGetEvent = true) - { - //This is simple so nothing more here, but often will be copying to a different output object or some other ops - var ret = await ct.PM.SingleOrDefaultAsync(z => z.Id == fetchId); - if (logTheGetEvent && ret != null) - { - //Log - await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, fetchId, BizType, AyaEvent.Retrieved), ct); - } - return ret; - } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE - - //Called from route and also seeder - internal async Task CreateAsync(long? workorderTemplateId, long? customerId, uint? serial) + // + internal async Task PMCreateAsync(PM newObject, bool populateViz = true) { - //Create and save to db a new workorder and return it - //NOTE: Serial can be specified or edited after the fact in a limited way by full role specfic only!! (service manager, bizadminfull, accounting maybe) - - if (serial != null && !Authorized.HasAnyRole(CurrentUserRoles, RolesAllowedToChangeSerial)) + using (var transaction = await ct.Database.BeginTransactionAsync()) { - AddError(ApiErrorCode.NOT_AUTHORIZED, "Serial"); + await PMValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + await PMBizActionsAsync(AyaEvent.Created, newObject, null, null); + newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.PM.AddAsync(newObject); + await ct.SaveChangesAsync(); + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, BizType, AyaEvent.Created), ct); + await PMSearchIndexAsync(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 (PMItem wi in newObject.Items) + { + foreach (PMItemPart wip in wi.Parts) + await PartBizActionsAsync(AyaEvent.Created, wip, null, null); + foreach (PMItemLoan 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 PMPopulateVizFields(newObject, true, false); + if (newObject.GenCopyAttachmentsFrom != null && !newObject.GenCopyAttachmentsFrom.IsEmpty) + { + //copy attachment from existing object + await AttachmentBiz.DuplicateAttachments(newObject.GenCopyAttachmentsFrom, new AyaTypeId(AyaType.PM, newObject.Id), ct); + newObject.GenCopyAttachmentsFrom = null;//so it doesn't get returned + } + await PMHandlePotentialNotificationEvent(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 PMGetFullAsync(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.PM.AsSplitQuery().AsNoTracking() + .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 PMGetAsync(long id, bool populateDisplayFields, bool logTheGetEvent = true, bool populateForReporting = false) + { + + var ret = await PMGetFullAsync(id); + + if (ret != null) + { + 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 (PMItem wi in ret.Items) + { + var userIsSelfScheduledOnThisItem = false; + foreach (PMItemScheduledUser 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 (PMItem 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 PMPopulateVizFields(ret, false, populateForReporting); + + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, BizType, AyaEvent.Retrieved), ct); + } + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task PMPutAsync(PM 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 + PM dbObject = await ct.PM.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; } - // await ValidateAsync(inObj, null); - // if (HasErrors) - // return null; - // else - // { - //do stuff with PM - PM o = new PM(); - // o.Serial = serial ?? ServerBootConfig.PM_SERIAL.GetNext(); + putObject.Tags = TagBiz.NormalizeTags(putObject.Tags); + putObject.CustomFields = JsonUtil.CompactJson(putObject.CustomFields); + await PMValidateAsync(putObject, dbObject); + if (HasErrors) + return null; + await PMBizActionsAsync(AyaEvent.Modified, putObject, dbObject, null); - //TODO: template - //TODO: CUSTOMER ID + long? newContractId = null; + if (putObject.ContractId != dbObject.ContractId)//manual change of contract + { + newContractId = putObject.ContractId; + await GetCurrentContractFromContractIdAsync(newContractId); + } - //Save to db - await ct.PM.AddAsync(o); - await ct.SaveChangesAsync(); - //Handle child and associated items: - await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, o.Id, BizType, AyaEvent.Created), ct); - await SearchIndexAsync(o, true); - // await NotifyEventProcessor.HandlePotentialNotificationEvent(AyaEvent.Created, newObject); - // await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, o.Tags, null); - - return o; - //} + ct.Replace(dbObject, putObject); + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!await PMExistsAsync(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 PMSearchIndexAsync(putObject, false); + await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags); + await PMPopulateVizFields(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 PMHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); + return putObject; } + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + internal async Task PMDeleteAsync(long id) + { + using (var transaction = await ct.Database.BeginTransactionAsync()) + { + try + { + PM dbObject = await ct.PM.AsNoTracking().Where(z => z.Id == id).FirstOrDefaultAsync();// PMGetAsync(id, false); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND); + return false; + } + PMValidateCanDelete(dbObject); + if (HasErrors) + return false; + + //collect the child id's to delete + var ItemIds = await ct.PMItem.AsNoTracking().Where(z => z.PMId == id).Select(z => z.Id).ToListAsync(); + + //Delete children + foreach (long ItemId in ItemIds) + if (!await ItemDeleteAsync(ItemId, transaction)) + return false; + + ct.PM.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 PMHandlePotentialNotificationEvent(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 PMBizActionsAsync(AyaEvent ayaEvent, PM newObj, PM 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(PM 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(PM 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 GetPMIdFromRelativeAsync(AyaType ayaType, long id, AyContext ct) + { + ParentAndChildItemId w = new ParentAndChildItemId(); + long itemid = 0; + switch (ayaType) + { + case AyaType.PM: + w.ParentId = id; + w.ChildItemId = 0; + return w; + case AyaType.PMItem: + itemid = id; + break; + case AyaType.PMItemExpense: + itemid = await ct.PMItemExpense.AsNoTracking().Where(z => z.Id == id).Select(z => z.PMItemId).SingleOrDefaultAsync(); + break; + case AyaType.PMItemLabor: + itemid = await ct.PMItemLabor.AsNoTracking().Where(z => z.Id == id).Select(z => z.PMItemId).SingleOrDefaultAsync(); + break; + case AyaType.PMItemLoan: + itemid = await ct.PMItemLoan.AsNoTracking().Where(z => z.Id == id).Select(z => z.PMItemId).SingleOrDefaultAsync(); + break; + case AyaType.PMItemPart: + itemid = await ct.PMItemPart.AsNoTracking().Where(z => z.Id == id).Select(z => z.PMItemId).SingleOrDefaultAsync(); + break; + case AyaType.PMItemScheduledUser: + itemid = await ct.PMItemScheduledUser.AsNoTracking().Where(z => z.Id == id).Select(z => z.PMItemId).SingleOrDefaultAsync(); + break; + case AyaType.PMItemTask: + itemid = await ct.PMItemTask.AsNoTracking().Where(z => z.Id == id).Select(z => z.PMItemId).SingleOrDefaultAsync(); + break; + case AyaType.PMItemTravel: + itemid = await ct.PMItemTravel.AsNoTracking().Where(z => z.Id == id).Select(z => z.PMItemId).SingleOrDefaultAsync(); + break; + case AyaType.PMItemOutsideService: + itemid = await ct.PMItemOutsideService.AsNoTracking().Where(z => z.Id == id).Select(z => z.PMItemId).SingleOrDefaultAsync(); + break; + case AyaType.PMItemUnit: + itemid = await ct.PMItemUnit.AsNoTracking().Where(z => z.Id == id).Select(z => z.PMItemId).SingleOrDefaultAsync(); + break; + default: + throw new System.NotSupportedException($"PMBiz::GetPMIdFromRelativeAsync -> AyaType {ayaType.ToString()} is not supported"); + } + + w.ParentId = await ct.PMItem.AsNoTracking() + .Where(z => z.Id == itemid) + .Select(z => z.PMId) + .SingleOrDefaultAsync(); + w.ChildItemId = itemid; + return w; + + + } // //////////////////////////////////////////////////////////////////////////////////////////////// - // //DUPLICATE - // // - - // internal async Task DuplicateAsync(PM dbObject) - // { - // await Task.CompletedTask; - // throw new System.NotImplementedException("STUB: WORKORDER DUPLICATE"); - // // PM outObj = new PM(); - // // CopyObject.Copy(dbObject, outObj, "Wiki"); - // // // outObj.Name = Util.StringUtil.NameUniquify(outObj.Name, 255); - // // //generate unique name - // // string newUniqueName = string.Empty; - // // bool NotUnique = true; - // // long l = 1; - // // do - // // { - // // newUniqueName = Util.StringUtil.UniqueNameBuilder(dbObject.Name, l++, 255); - // // NotUnique = await ct.PM.AnyAsync(z => z.Name == newUniqueName); - // // } while (NotUnique); - - // // outObj.Name = newUniqueName; - - - // // outObj.Id = 0; - // // outObj.Concurrency = 0; - - // // await ct.PM.AddAsync(outObj); - // // await ct.SaveChangesAsync(); - - // // //Handle child and associated items: - // // await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, outObj.Id, BizType, AyaEvent.Created), ct); - // // await SearchIndexAsync(outObj, true); - // // await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, outObj.Tags, null); - // // return outObj; - + // //GET WORKORDER ID FOR WORK ORDER NUMBER + // // + // internal static async Task GetPMIdForNumberAsync(long woNumber, AyContext ct) + // { + // return await ct.PM.AsNoTracking() + // .Where(z => z.Serial == woNumber) + // .Select(z => z.Id) + // .SingleOrDefaultAsync(); // } - //################################################################################################################################################### - //################################################################################################################################################### - // WARNING! THIS OBJECT IS AN INITIAL TEST VERSION NOT UP TO CURRENT STANDARDS, SEE PARTASSEMBLYBIZ / some of WORKORDERBIZ FOR HOW THIS SHOULD BE CODED - //################################################################################################################################################### - //################################################################################################################################################### - - - //////////////////////////////////////////////////////////////////////////////////////////////// - //UPDATE - // - - //put - internal async Task PutAsync(PM dbObject, PM putObj) - { - - // make a snapshot of the original for validation but update the original to preserve workflow - PM SnapshotOfOriginalDBObj = new PM(); - CopyObject.Copy(dbObject, SnapshotOfOriginalDBObj); - - //Replace the db object with the PUT object - CopyObject.Copy(putObj, dbObject, "Id,Serial"); - - //if user has rights then change it, otherwise just ignore it and do the rest - if (SnapshotOfOriginalDBObj.Serial != putObj.Serial && Authorized.HasAnyRole(CurrentUserRoles, RolesAllowedToChangeSerial)) - { - dbObject.Serial = putObj.Serial; - } - - dbObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); - dbObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); - - //Set "original" value of concurrency token to input token - //this will allow EF to check it out - ct.Entry(dbObject).OriginalValues["Concurrency"] = putObj.Concurrency; - - - await ValidateAsync(dbObject, SnapshotOfOriginalDBObj); - if (HasErrors) - return false; - - //Log event and save context - await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, BizType, AyaEvent.Modified), ct); - await SearchIndexAsync(dbObject, false); - await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, dbObject.Tags, SnapshotOfOriginalDBObj.Tags); - - return true; - } - //////////////////////////////////////////////////////////////////////////////////////////////// //SEARCH - // - private async Task SearchIndexAsync(PM obj, bool isNew) + // + private async Task PMSearchIndexAsync(PM obj, bool isNew) { var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, BizType); DigestSearchText(obj, SearchParams); @@ -213,7 +573,7 @@ namespace AyaNova.Biz public async Task GetSearchResultSummary(long id) { - var obj = await ct.PM.SingleOrDefaultAsync(z => z.Id == id); + var obj = await ct.PM.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; @@ -222,35 +582,72 @@ namespace AyaNova.Biz public void DigestSearchText(PM obj, Search.SearchIndexProcessObjectParameters searchParams) { if (obj != null) - searchParams.AddText(obj.Notes) - .AddText(obj.Serial) - .AddText(obj.Wiki) - .AddText(obj.Tags) - .AddCustomFields(obj.CustomFields); + searchParams.AddText(obj.Notes).AddText(obj.Serial).AddText(obj.Wiki).AddText(obj.Tags).AddCustomFields(obj.CustomFields); } + + //////////////////////////////////////////////////////////////////////////////////////////////// - //DELETE + // "The Andy" notification helper // - internal async Task DeleteAsync(PM dbObject) + // (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) { - await Task.CompletedTask; - throw new System.NotImplementedException("STUB: WORKORDER DELETE"); - //Determine if the object can be deleted, do the deletion tentatively - //Probably also in here deal with tags and associated search text etc + var wo = await ct.PM.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; - //NOT REQUIRED NOW BUT IF IN FUTURE ValidateCanDelete(dbObject); - // if (HasErrors) - // return false; - // ct.PM.Remove(dbObject); - // await ct.SaveChangesAsync(); + decimal GrandTotal = 0m; + //update pricing + foreach (PMItem wi in wo.Items) + { + foreach (PMItemExpense o in wi.Expenses) + await ExpensePopulateVizFields(o, true); + foreach (PMItemLabor o in wi.Labors) + await LaborPopulateVizFields(o, true); + foreach (PMItemLoan o in wi.Loans) + await LoanPopulateVizFields(o, null, true); + foreach (PMItemPart o in wi.Parts) + await PartPopulateVizFields(o, true); + foreach (PMItemTravel o in wi.Travels) + await TravelPopulateVizFields(o, true); + foreach (PMItemOutsideService o in wi.OutsideServices) + await OutsideServicePopulateVizFields(o, true); + } - // //Log event - // await EventLogProcessor.DeleteObjectLogAsync(UserId, BizType, dbObject.Id, dbObject.Serial.ToString(), ct); - // await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, BizType); - // await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); - // return true; + foreach (PMItem wi in wo.Items) + { + foreach (PMItemExpense o in wi.Expenses) + GrandTotal += o.LineTotalViz; + foreach (PMItemLabor o in wi.Labors) + GrandTotal += o.LineTotalViz; + foreach (PMItemLoan o in wi.Loans) + GrandTotal += o.LineTotalViz; + foreach (PMItemPart o in wi.Parts) + GrandTotal += o.LineTotalViz; + foreach (PMItemTravel o in wi.Travels) + GrandTotal += o.LineTotalViz; + foreach (PMItemOutsideService o in wi.OutsideServices) + GrandTotal += o.LineTotalViz; + } + + return GrandTotal; } @@ -260,18 +657,46 @@ namespace AyaNova.Biz // //Can save or update? - private async Task ValidateAsync(PM proposedObj, PM currentObj) + private async Task PMValidateAsync(PM proposedObj, PM 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 + } + + + /* + + todo: quote status list first, it's a table of created items, keep properties from v7 but add the following properties: + SelectRoles - who can select the status (still shows if they can't select but that's the current status, like active does) + This is best handled at the client. It prefetches all the status out of the normal picklist process, more like how other things are separately handled now without a picklist + client then knows if a status is available or not and can process to only present available ones + #### Server can use a biz rule to ensure that it can't be circumvented + UI defaults to any role + DeselectRoles - who can unset this status (important for process control) + UI defaults to any role + CompletedStatus bool - this is a final status indicating all work on the quote is completed, affects notification etc + UI defaults to false but when set to true auto sets lockworkorder to true (but user can just unset lockworkorder) + LockWorkorder - this status is considered read only and the quote is locked + Just a read only thing, can just change status to "unlock" it + to support states where no one should work on a wo for whatever reason but it's not necessarily completed + e.g. "Hold for inspection", "On hold" generally etc + */ // //Name required // if (string.IsNullOrWhiteSpace(proposedObj.Name)) // AddError(ApiErrorCode.VALIDATION_REQUIRED, "Name"); - // //If name is otherwise OK, check that name is unique // if (!PropertyHasErrors("Name")) // { @@ -299,20 +724,4444 @@ namespace AyaNova.Biz } - //Can delete? - // private void ValidateCanDelete(PM inObj) + + private void PMValidateCanDelete(PM dbObject) + { + //Check restricted role preventing create + if (UserIsRestrictedType) + { + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return;//this is a completely disqualifying error + } + //FOREIGN KEY CHECKS + //these are examples copied from customer for when other objects are actually referencing them + // if (await ct.User.AnyAsync(m => m.CustomerId == inObj.Id)) + // AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("User")); + // if (await ct.Unit.AnyAsync(m => m.CustomerId == inObj.Id)) + // AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Unit")); + // if (await ct.CustomerServiceRequest.AnyAsync(m => m.CustomerId == inObj.Id)) + // AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("CustomerServiceRequest")); + // if (await ct.PurchaseOrder.AnyAsync(m => m.DropShipToCustomerId == inObj.Id)) + // AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PurchaseOrder")); + } + + + //############### NOTIFICATION TODO + /* + + todo: quote notifications remove #30 and #32 as redundant + WorkorderStatusChange = 4,//* Workorder object, any *change* of status including from no status (new) to a specific conditional status ID value + + WorkorderStatusAge = 24,//* Workorder object Created / Updated, conditional on exact status selected IdValue, Tags conditional, advance notice can be set + + //THESE TWO ARE REDUNDANT: + + this is actually workorderstatuschange because can just pick any status under workorderstatuschange to be notified about + WorkorderCompleted = 30, //*travel work order is set to any status that is flagged as a "Completed" type of status. Customer & User + + //This one could be accomplished with WorkorderStatusAge, just pick a Completed status and set a time frame and wala! + WorkorderCompletedFollowUp = 32, //* travel quote closed status follow up again after this many TIMESPAN + + todo: CHANGE WorkorderCompletedStatusOverdue = 15,//* Workorder object not set to a "Completed" flagged quote status type in selected time span from creation of quote + Change this to a new type that is based on so many days *without* being set to a particular status + but first check if tied to contract response time stuff, how that's handled + that's closeby date in v7 but isn't that deprecated now without a "close"? + maybe I do need the Completed status bool thing above + + */ + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // 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 PMGetPartialAsync(AyaType ayaType, long id, bool includeWoItemDescendants, bool populateForReporting) + { + //if it's the entire quote just get, populate and return as normal + if (ayaType == AyaType.PM) + return await PMGetAsync(id, true, false, populateForReporting); + + var wid = await GetPMIdFromRelativeAsync(ayaType, id, ct); + + //get header only + var ret = await ct.PM.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... + + + PMItem quoteitem = null; + + //it's requesting a fully populated woitem so do that here + if (includeWoItemDescendants) + { + + quoteitem = await ct.PMItem.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.PMItem.AsNoTracking().SingleOrDefaultAsync(x => x.Id == wid.ChildItemId); + + switch (ayaType) + { + case AyaType.PMItemExpense: + quoteitem.Expenses.Add(await ct.PMItemExpense.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.PMItemLabor: + quoteitem.Labors.Add(await ct.PMItemLabor.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.PMItemLoan: + quoteitem.Loans.Add(await ct.PMItemLoan.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.PMItemPart: + quoteitem.Parts.Add(await ct.PMItemPart.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + + case AyaType.PMItemScheduledUser: + quoteitem.ScheduledUsers.Add(await ct.PMItemScheduledUser.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.PMItemTask: + quoteitem.Tasks.Add(await ct.PMItemTask.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.PMItemTravel: + quoteitem.Travels.Add(await ct.PMItemTravel.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.PMItemOutsideService: + quoteitem.OutsideServices.Add(await ct.PMItemOutsideService.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.PMItemUnit: + quoteitem.Units.Add(await ct.PMItemUnit.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + } + } + + if (quoteitem != null) + ret.Items.Add(quoteitem); + + await PMPopulateVizFields(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 PMGetPartialAsync(dataListSelectedRequest.AType, batchId, dataListSelectedRequest.IncludeWoItemDescendants, true)); + + #region unnecessary shit removed + //This is unnecessary because the re-ordering bit is only needed when the records are fetched in batches directly from the sql server as they + //return in db natural order and need to be put back into the same order as the ID List + //Here in the quote however, this code is fetching individually one at a time so they are always going to be in the correct order so this re-ordering is unnecessary + //I'm keeping this here for future reference when I ineveitably wonder what the hell is happening here :) + + + //order the results back into original + //IEnumerable orderedList = null; + + //TODO: WHAT IS THIS BATCH RESULT ORDERING CODE REALLY DOING AND CAN IT BE REMOVED / CHANGED???? + //isn't it alredy working in order? If not maybe simply reversed so reverse it again before querying above or...?? + + //todo: can't assume the grandchild item is index 0 anymore as we might have multiple of them if includedescendants is true + //so need to find index first then do this + // switch (dataListSelectedRequest.AType) + // { + // case AyaType.PM: + // orderedList = from id in batch join z in batchResults on id equals z.Id select z; + // break; + // case AyaType.PMItem: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Id select z; + // break; + // case AyaType.PMItemExpense: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Expenses[0].Id select z; + // break; + // case AyaType.PMItemLabor: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Labors[0].Id select z; + // break; + // case AyaType.PMItemLoan: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Loans[0].Id select z; + // break; + // case AyaType.PMItemPart: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Parts[0].Id select z; + // break; + // case AyaType.PMItemPartRequest: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].PartRequests[0].Id select z; + // break; + // case AyaType.PMItemScheduledUser: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].ScheduledUsers[0].Id select z; + // break; + // case AyaType.PMItemTask: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Tasks[0].Id select z; + // break; + // case AyaType.PMItemTravel: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Travels[0].Id select z; + // break; + // case AyaType.PMItemOutsideService: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].OutsideServices[0].Id select z; + // break; + // case AyaType.PMItemUnit: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Units[0].Id select z; + // break; + // } + + //foreach (PM w in orderedList) + #endregion unnecessary shit + + foreach (PM w in batchResults) + { + var jo = JObject.FromObject(w); + + //PM header custom fields + if (!JsonUtil.JTokenIsNullOrEmpty(jo["CustomFields"])) + jo["CustomFields"] = JObject.Parse((string)jo["CustomFields"]); + + //PMItem custom fields + foreach (JObject jItem in jo["Items"]) + { + if (!JsonUtil.JTokenIsNullOrEmpty(jItem["CustomFields"])) + jItem["CustomFields"] = JObject.Parse((string)jItem["CustomFields"]); + + //PMItemUnit 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 PMPopulateVizFields(PM 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.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.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) // { - // //whatever needs to be check to delete this object + // 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 PMCreateAsync(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($"PM.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 GetPMGraphItem(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 DeletePMGraphItem(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 PutPMGraphItem(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 PMHandlePotentialNotificationEvent(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; + + PM oProposed = (PM)proposedObj; + proposedObj.Name = oProposed.Serial.ToString(); + + //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 + + // PM oCurrent = null; + // bool SameTags = true; + // if (currentObj != null) + // { + // oCurrent = (PM)currentObj; + // SameTags = NotifyEventHelper.TwoObjectsHaveSameTags(proposedObj.Tags, currentObj.Tags); + // } + + + + }//end of process notifications + + + + #endregion quote level + + + + + + /* + ██╗████████╗███████╗███╗ ███╗███████╗ + ██║╚══██╔══╝██╔════╝████╗ ████║██╔════╝ + ██║ ██║ █████╗ ██╔████╔██║███████╗ + ██║ ██║ ██╔══╝ ██║╚██╔╝██║╚════██║ + ██║ ██║ ███████╗██║ ╚═╝ ██║███████║ + ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚══════╝ + */ + + #region PMItem level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task ItemExistsAsync(long id) + { + return await ct.PMItem.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task ItemCreateAsync(PMItem newObject) + { + await ItemValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.PMItem.AddAsync(newObject); + await ct.SaveChangesAsync(); + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, AyaType.PMItem, 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.PMItem.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.PMItem, AyaEvent.Retrieved), ct); + return ret; + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task ItemPutAsync(PMItem 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.PMItem.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.PMItem, 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.PMItem.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.PMItemExpense.Where(z => z.PMItemId == id).Select(z => z.Id).ToListAsync(); + var LaborIds = await ct.PMItemLabor.Where(z => z.PMItemId == id).Select(z => z.Id).ToListAsync(); + var LoanIds = await ct.PMItemLoan.Where(z => z.PMItemId == id).Select(z => z.Id).ToListAsync(); + var PartIds = await ct.PMItemPart.Where(z => z.PMItemId == id).Select(z => z.Id).ToListAsync(); + + var ScheduledUserIds = await ct.PMItemScheduledUser.Where(z => z.PMItemId == id).Select(z => z.Id).ToListAsync(); + var TaskIds = await ct.PMItemTask.Where(z => z.PMItemId == id).Select(z => z.Id).ToListAsync(); + var TravelIds = await ct.PMItemTravel.Where(z => z.PMItemId == id).Select(z => z.Id).ToListAsync(); + var UnitIds = await ct.PMItemUnit.Where(z => z.PMItemId == id).Select(z => z.Id).ToListAsync(); + var OutsideServiceIds = await ct.PMItemOutsideService.Where(z => z.PMItemId == 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.PMItem.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "wo:" + dbObject.PMId.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(PMItem obj, bool isNew) + { + //SEARCH INDEXING + var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, AyaType.PMItem); + 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.PMItem.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(PMItem o, bool populateForReporting) + { + // if (o.PMOverseerId != null) + // o.PMOverseerViz = await ct.User.AsNoTracking().Where(x => x.Id == o.PMOverseerId).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(PMItem proposedObj, PMItem currentObj) + { + //run validation and biz rules + bool isNew = currentObj == null; + + //does it have a valid quote id + if (proposedObj.PMId == 0) + AddError(ApiErrorCode.VALIDATION_REQUIRED, "PMId"); + else if (!await PMExistsAsync(proposedObj.PMId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "PMId"); + } + + //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.PMId == 0) + AddError(ApiErrorCode.VALIDATION_REQUIRED, "PMId"); + + //Check restricted role preventing create + if (isNew && UserIsRestrictedType) + { + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + 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.PMItem.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(PMItem 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.PMItem)) + { + 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; + + PMItem oProposed = (PMItem)proposedObj; + + var qid = await GetPMIdFromRelativeAsync(AyaType.PMItem, oProposed.PMId, ct); + var WorkorderInfo = await ct.PM.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 + oProposed.Name = WorkorderInfo.Serial.ToString(); + + //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 PMItemExpense level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task ExpenseExistsAsync(long id) + { + return await ct.PMItemExpense.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task ExpenseCreateAsync(PMItemExpense newObject) + { + await ExpenseValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + await ct.PMItemExpense.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.PMItemExpense.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(PMItemExpense 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.PMItemExpense.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.PMItemId.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(PMItemExpense 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(PMItemExpense 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(PMItemExpense proposedObj, PMItemExpense 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.PMItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "PMItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.PMItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "PMItemId"); + 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.PMItemExpense.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(PMItemExpense 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.PMItemExpense)) + { + 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; + PMItemExpense oProposed = (PMItemExpense)proposedObj; + var qid = await GetPMIdFromRelativeAsync(AyaType.PMItem, oProposed.PMItemId, ct); + var WorkorderInfo = await ct.PM.AsNoTracking().Where(x => x.Id == qid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + oProposed.Tags = WorkorderInfo.Tags; + + //STANDARD EVENTS FOR ALL OBJECTS + await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); + + //SPECIFIC EVENTS FOR THIS OBJECT + + + + }//end of process notifications + #endregion work order item EXPENSE level + + + /* + ██╗ █████╗ ██████╗ ██████╗ ██████╗ + ██║ ██╔══██╗██╔══██╗██╔═══██╗██╔══██╗ + ██║ ███████║██████╔╝██║ ██║██████╔╝ + ██║ ██╔══██║██╔══██╗██║ ██║██╔══██╗ + ███████╗██║ ██║██████╔╝╚██████╔╝██║ ██║ + ╚══════╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝ + */ + + #region PMItemLabor level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task LaborExistsAsync(long id) + { + return await ct.PMItemLabor.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task LaborCreateAsync(PMItemLabor 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.PMItemLabor.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.PMItemLabor.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(PMItemLabor 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.PMItemLabor.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.PMItemId.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(PMItemLabor 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(PMItemLabor 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 GetCurrentPMContractFromRelatedAsync(AyaType.PMItem, o.PMItemId); + 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(PMItemLabor proposedObj, PMItemLabor currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + //run validation and biz rules + bool isNew = currentObj == null; + + if (proposedObj.PMItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "PMItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.PMItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "PMItemId"); + 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.PMItemLabor.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(PMItemLabor 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.PMItemLabor)) + { + 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; + PMItemLabor oProposed = (PMItemLabor)proposedObj; + var wid = await GetPMIdFromRelativeAsync(AyaType.PMItem, oProposed.PMItemId, ct); + var WorkorderInfo = await ct.PM.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 PMItemLoan level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task LoanExistsAsync(long id) + { + return await ct.PMItemLoan.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task LoanCreateAsync(PMItemLoan newObject) + { + await LoanValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + await LoanBizActionsAsync(AyaEvent.Created, newObject, null, null); + await ct.PMItemLoan.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.PMItemLoan.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(PMItemLoan 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.PMItemLoan.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.PMItemId.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(PMItemLoan 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(PMItemLoan 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, PMItemLoan newObj, PMItemLoan 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(PMItemLoan proposedObj, PMItemLoan 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.PMItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "PMItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.PMItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "PMItemId"); + 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.PMItemLoan.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(PMItemLoan 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.PMItemLoan)) + { + 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; + PMItemLoan oProposed = (PMItemLoan)proposedObj; + var wid = await GetPMIdFromRelativeAsync(AyaType.PMItem, oProposed.PMItemId, ct); + var WorkorderInfo = await ct.PM.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 PMItemOutsideService level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task OutsideServiceExistsAsync(long id) + { + return await ct.PMItemOutsideService.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task OutsideServiceCreateAsync(PMItemOutsideService newObject) + { + await OutsideServiceValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + // newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.PMItemOutsideService.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.PMItemOutsideService.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, AyaType.PMItemOutsideService, AyaEvent.Retrieved), ct); + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task OutsideServicePutAsync(PMItemOutsideService putObject) + { + PMItemOutsideService 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.PMItemOutsideService.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.PMItemId.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(PMItemOutsideService 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(PMItemOutsideService 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(PMItemOutsideService proposedObj, PMItemOutsideService 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.PMItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "PMItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.PMItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "PMItemId"); + 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.PMItemOutsideService.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(PMItemOutsideService 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.PMItemOutsideService)) + { + 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; + + PMItemOutsideService oProposed = (PMItemOutsideService)proposedObj; + var wid = await GetPMIdFromRelativeAsync(AyaType.PMItem, oProposed.PMItemId, ct); + var WorkorderInfo = await ct.PM.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.PMItemOutsideService, + 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.PMItemOutsideService, + 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) + { + PMItemOutsideService oCurrent = (PMItemOutsideService)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.PMItemOutsideService, + 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.PMItemOutsideService, + 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 PMItemPart level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task PartExistsAsync(long id) + { + return await ct.PMItemPart.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task CreatePartAsync(PMItemPart 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.PMItemPart.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.PMItemPart.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(PMItemPart putObject) + { + using (var transaction = await ct.Database.BeginTransactionAsync()) + { + PMItemPart 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.PMItemPart.Remove(dbObject); + await ct.SaveChangesAsync(); + if (HasErrors) + { + await transaction.RollbackAsync(); + return false; + } + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.PMItemId.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(PMItemPart 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(PMItemPart 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 GetCurrentPMContractFromRelatedAsync(AyaType.PMItem, o.PMItemId); + 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, PMItemPart newObj, PMItemPart 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(PMItemPart proposedObj, PMItemPart 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.PMItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "PMItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.PMItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "PMItemId"); + 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.PMItemPart.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(PMItemPart 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.PMItemPart)) + { + 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; + PMItemPart oProposed = (PMItemPart)proposedObj; + var wid = await GetPMIdFromRelativeAsync(AyaType.PMItem, oProposed.PMItemId, ct); + var WorkorderInfo = await ct.PM.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 PMItemScheduledUser level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task ScheduledUserExistsAsync(long id) + { + return await ct.PMItemScheduledUser.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task ScheduledUserCreateAsync(PMItemScheduledUser newObject) + { + await ScheduledUserValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + //newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.PMItemScheduledUser.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.PMItemScheduledUser.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(PMItemScheduledUser putObject) + { + PMItemScheduledUser 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.PMItemScheduledUser.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.PMItemId.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(PMItemScheduledUser 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(PMItemScheduledUser proposedObj, PMItemScheduledUser currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + //run validation and biz rules + bool isNew = currentObj == null; + + if (proposedObj.PMItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "PMItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.PMItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "PMItemId"); + 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; + } + + + 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.PMItemScheduledUser.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.PMItemScheduledUser.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(PMItemScheduledUser 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.PMItemScheduledUser)) + { + 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; + PMItemScheduledUser oProposed = (PMItemScheduledUser)proposedObj; + var wid = await GetPMIdFromRelativeAsync(AyaType.PMItem, oProposed.PMItemId, ct); + var WorkorderInfo = await ct.PM.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.PMItemScheduledUser, + 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.PMItemScheduledUser, + 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.PMItemScheduledUser, + 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 PMItemTask level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task TaskExistsAsync(long id) + { + return await ct.PMItemTask.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task TaskCreateAsync(PMItemTask newObject) + { + await TaskValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + //newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.PMItemTask.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.PMItemTask.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(PMItemTask putObject) + { + PMItemTask 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.PMItemTask.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.PMItemId.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(PMItemTask 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(PMItemTask 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(PMItemTask proposedObj, PMItemTask currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + //run validation and biz rules + bool isNew = currentObj == null; + + if (proposedObj.PMItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "PMItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.PMItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "PMItemId"); + 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.PMItemTask.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(PMItemTask 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.PMItemTask)) + { + 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; + PMItemTask oProposed = (PMItemTask)proposedObj; + var wid = await GetPMIdFromRelativeAsync(AyaType.PMItem, oProposed.PMItemId, ct); + var WorkorderInfo = await ct.PM.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 PMItemTravel level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task TravelExistsAsync(long id) + { + return await ct.PMItemTravel.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task TravelCreateAsync(PMItemTravel newObject) + { + await TravelValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + await ct.PMItemTravel.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.PMItemTravel.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(PMItemTravel putObject) + { + PMItemTravel 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.PMItemTravel.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.PMItemId.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(PMItemTravel 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(PMItemTravel 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 GetCurrentPMContractFromRelatedAsync(AyaType.PMItem, o.PMItemId); + 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(PMItemTravel proposedObj, PMItemTravel currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + //run validation and biz rules + bool isNew = currentObj == null; + + if (proposedObj.PMItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "PMItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.PMItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "PMItemId"); + 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.PMItemTravel.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(PMItemTravel 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.PMItemTravel)) + { + 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; + PMItemTravel oProposed = (PMItemTravel)proposedObj; + var wid = await GetPMIdFromRelativeAsync(AyaType.PMItem, oProposed.PMItemId, ct); + var WorkorderInfo = await ct.PM.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 PMItemUnit level + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task UnitExistsAsync(long id) + { + return await ct.PMItemUnit.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task UnitCreateAsync(PMItemUnit 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.PMItemUnit.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.PMItemUnit.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(PMItemUnit putObject) + { + PMItemUnit 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.PMItemUnit.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.PMItemId.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(PMItemUnit 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, PMItemUnit newObj, PMItemUnit 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(PMItemUnit 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(PMItemUnit proposedObj, PMItemUnit 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.PMItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "PMItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.PMItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "PMItemId"); + 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.PMItemUnit.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(PMItemUnit 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.PMItemUnit)) + { + 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; + PMItemUnit oProposed = (PMItemUnit)proposedObj; + var wid = await GetPMIdFromRelativeAsync(AyaType.PMItem, oProposed.PMItemId, ct); + var WorkorderInfo = await ct.PM.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 GetPMGraphItem(AyaType ayaType, long id) + { + switch (ayaType) + { + case AyaType.PM: + return await PMGetAsync(id, false) as ICoreBizObjectModel; + case AyaType.PMItem: + return await ItemGetAsync(id, false); + case AyaType.PMItemExpense: + return await ExpenseGetAsync(id, false); + case AyaType.PMItemLabor: + return await LaborGetAsync(id, false); + case AyaType.PMItemLoan: + return await LoanGetAsync(id, false); + case AyaType.PMItemPart: + return await PartGetAsync(id, false); + case AyaType.PMItemScheduledUser: + return await ScheduledUserGetAsync(id, false); + case AyaType.PMItemTask: + return await TaskGetAsync(id, false); + case AyaType.PMItemTravel: + return await TravelGetAsync(id, false); + case AyaType.PMItemUnit: + return await UnitGetAsync(id, false); + case AyaType.PMItemOutsideService: + return await OutsideServiceGetAsync(id, false); + default: + throw new System.ArgumentOutOfRangeException($"PM::GetPMGraphItem -> Invalid ayaType{ayaType}"); + } + } + + public async Task PutPMGraphItem(AyaType ayaType, ICoreBizObjectModel o) + { + ClearErrors(); + switch (ayaType) + { + case AyaType.PM: + if (o is PM) + { + PM dto = new PM(); + CopyObject.Copy(o, dto); + return await PMPutAsync((PM)dto); + } + return await PMPutAsync((PM)o) as ICoreBizObjectModel; + case AyaType.PMItem: + if (o is PMItem) + { + PMItem dto = new PMItem(); + CopyObject.Copy(o, dto); + return await ItemPutAsync((PMItem)dto); + } + return await ItemPutAsync((PMItem)o); + case AyaType.PMItemExpense: + return await ExpensePutAsync((PMItemExpense)o); + case AyaType.PMItemLabor: + return await LaborPutAsync((PMItemLabor)o); + case AyaType.PMItemLoan: + return await LoanPutAsync((PMItemLoan)o); + case AyaType.PMItemPart: + return await PartPutAsync((PMItemPart)o); + + case AyaType.PMItemScheduledUser: + return await ScheduledUserPutAsync((PMItemScheduledUser)o); + case AyaType.PMItemTask: + return await TaskPutAsync((PMItemTask)o); + case AyaType.PMItemTravel: + return await TravelPutAsync((PMItemTravel)o); + case AyaType.PMItemUnit: + return await UnitPutAsync((PMItemUnit)o); + case AyaType.PMItemOutsideService: + return await OutsideServicePutAsync((PMItemOutsideService)o); + default: + throw new System.ArgumentOutOfRangeException($"PM::PutPMGraphItem -> Invalid ayaType{ayaType}"); + } + } + + public async Task DeletePMGraphItem(AyaType ayaType, long id) + { + switch (ayaType) + { + case AyaType.PM: + return await PMDeleteAsync(id); + case AyaType.PMItem: + return await ItemDeleteAsync(id); + case AyaType.PMItemExpense: + return await ExpenseDeleteAsync(id); + case AyaType.PMItemLabor: + return await LaborDeleteAsync(id); + case AyaType.PMItemLoan: + return await LoanDeleteAsync(id); + case AyaType.PMItemPart: + return await PartDeleteAsync(id); + case AyaType.PMItemScheduledUser: + return await ScheduledUserDeleteAsync(id); + case AyaType.PMItemTask: + return await TaskDeleteAsync(id); + case AyaType.PMItemTravel: + return await TravelDeleteAsync(id); + case AyaType.PMItemUnit: + return await UnitDeleteAsync(id); + case AyaType.PMItemOutsideService: + return await OutsideServiceDeleteAsync(id); + default: + throw new System.ArgumentOutOfRangeException($"PM::GetPMGraphItem -> 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 GetCurrentPMContractFromRelatedAsync(AyaType ayaType, long id) + { + if (mFetchedContractAlready == false) + { + var wid = await GetPMIdFromRelativeAsync(ayaType, id, ct); + var WoContractId = await ct.PM.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); + + } + + + + + + #endregion utility + - //Other job handlers here... ///////////////////////////////////////////////////////////////////// diff --git a/server/AyaNova/models/PM.cs b/server/AyaNova/models/PM.cs index d78f2416..4b385916 100644 --- a/server/AyaNova/models/PM.cs +++ b/server/AyaNova/models/PM.cs @@ -73,8 +73,7 @@ namespace AyaNova.Models //UTILITY FIELDS - [NotMapped] - public bool IsLockedAtServer { get; set; } = false;//signal to client that it came from the server in a locked state + [NotMapped] public string AlertViz { get; set; } = null;