Files
raven/server/AyaNova/biz/CustomerBiz.cs
2021-09-30 20:24:48 +00:00

615 lines
28 KiB
C#

using System;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Linq;
using AyaNova.Util;
using AyaNova.Api.ControllerHelpers;
using AyaNova.Models;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace AyaNova.Biz
{
internal class CustomerBiz : BizObject, IJobObject, ISearchAbleObject, IReportAbleObject, IExportAbleObject, IImportAbleObject, INotifiableObject
{
internal CustomerBiz(AyContext dbcontext, long currentUserId, long userTranslationId, AuthorizationRoles UserRoles)
{
ct = dbcontext;
UserId = currentUserId;
UserTranslationId = userTranslationId;
CurrentUserRoles = UserRoles;
BizType = AyaType.Customer;
}
internal static CustomerBiz GetBiz(AyContext ct, Microsoft.AspNetCore.Http.HttpContext httpContext = null)
{
if (httpContext != null)
return new CustomerBiz(ct, UserIdFromContext.Id(httpContext.Items), UserTranslationIdFromContext.Id(httpContext.Items), UserRolesFromContext.Roles(httpContext.Items));
else
return new CustomerBiz(ct, 1, ServerBootConfig.AYANOVA_DEFAULT_TRANSLATION_ID, AuthorizationRoles.BizAdmin);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//EXISTS
internal async Task<bool> ExistsAsync(long id)
{
return await ct.Customer.AnyAsync(z => z.Id == id);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//CREATE
//
internal async Task<Customer> CreateAsync(Customer newObject)
{
await ValidateAsync(newObject, null);
if (HasErrors)
return null;
else
{
newObject.Tags = TagBiz.NormalizeTags(newObject.Tags);
newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields);
await ct.Customer.AddAsync(newObject);
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, BizType, AyaEvent.Created), ct);
await SearchIndexAsync(newObject, true);
await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null);
await HandlePotentialNotificationEvent(AyaEvent.Created, newObject);
return newObject;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////
//GET
//
internal async Task<Customer> GetAsync(long id, bool logTheGetEvent = true)
{
var ret = await ct.Customer.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id);
if (logTheGetEvent && ret != null)
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, BizType, AyaEvent.Retrieved), ct);
return ret;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//UPDATE
//
internal async Task<Customer> PutAsync(Customer putObject)
{
var dbObject = await GetAsync(putObject.Id, false);
if (dbObject == null)
{
AddError(ApiErrorCode.NOT_FOUND, "id");
return null;
}
if (dbObject.Concurrency != putObject.Concurrency)
{
AddError(ApiErrorCode.CONCURRENCY_CONFLICT);
return null;
}
putObject.Tags = TagBiz.NormalizeTags(putObject.Tags);
putObject.CustomFields = JsonUtil.CompactJson(putObject.CustomFields);
await ValidateAsync(putObject, dbObject);
if (HasErrors) return null;
ct.Replace(dbObject, putObject);
try
{
await ct.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!await ExistsAsync(putObject.Id))
AddError(ApiErrorCode.NOT_FOUND);
else
AddError(ApiErrorCode.CONCURRENCY_CONFLICT);
return null;
}
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, putObject.Id, BizType, AyaEvent.Modified), ct);
await SearchIndexAsync(putObject, false);
await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags);
await HandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject);
return putObject;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//DELETE
//
internal async Task<bool> DeleteAsync(long id)
{
using (var transaction = await ct.Database.BeginTransactionAsync())
{
Customer dbObject = await GetAsync(id, false);
if (dbObject == null)
{
AddError(ApiErrorCode.NOT_FOUND);
return false;
}
await ValidateCanDeleteAsync(dbObject);
if (HasErrors)
return false;
//DELETE DIRECT CHILD / RELATED OBJECTS
//(note: the convention is to allow deletion of children created *in* the same UI area so this will delete contacts, customer notes, but not workorders of this customer for example)
//Also these are done through their biz objects as there are notification, search and other concerns to be handled
{
var IDList = await ct.User.AsNoTracking().Where(z => z.CustomerId == id).Select(z => z.Id).ToListAsync();
if (IDList.Count() > 0)
{
UserBiz b = new UserBiz(ct, UserId, UserTranslationId, CurrentUserRoles);
foreach (long ItemId in IDList)
if (!await b.DeleteAsync(ItemId, transaction))
{
AddError(ApiErrorCode.CHILD_OBJECT_ERROR, null, $"CustomerContact [{ItemId}]: {b.GetErrorsAsString()}");
return false;
}
}
}
{
var IDList = await ct.CustomerNote.AsNoTracking().Where(z => z.CustomerId == id).Select(z => z.Id).ToListAsync();
if (IDList.Count() > 0)
{
CustomerNoteBiz b = new CustomerNoteBiz(ct, UserId, UserTranslationId, CurrentUserRoles);
foreach (long ItemId in IDList)
if (!await b.DeleteAsync(ItemId, transaction))
{
AddError(ApiErrorCode.CHILD_OBJECT_ERROR, null, $"CustomerNote [{ItemId}]: {b.GetErrorsAsString()}");
return false;
}
}
}
{
var IDList = await ct.Review.AsNoTracking().Where(x => x.AType == AyaType.Customer && x.ObjectId == id).Select(x => x.Id).ToListAsync();
if (IDList.Count() > 0)
{
ReviewBiz b = new ReviewBiz(ct, UserId, UserTranslationId, CurrentUserRoles);
foreach (long ItemId in IDList)
if (!await b.DeleteAsync(ItemId, transaction))
{
AddError(ApiErrorCode.CHILD_OBJECT_ERROR, null, $"Review [{ItemId}]: {b.GetErrorsAsString()}");
return false;
}
}
}
ct.Customer.Remove(dbObject);
await ct.SaveChangesAsync();
//Log event
await EventLogProcessor.DeleteObjectLogAsync(UserId, BizType, dbObject.Id, dbObject.Name, ct);
await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, BizType, ct);
await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags);
await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct);
await transaction.CommitAsync();
await HandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject);
return true;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////
//SEARCH
//
private async Task SearchIndexAsync(Customer obj, bool isNew)
{
var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, BizType);
DigestSearchText(obj, SearchParams);
if (isNew)
await Search.ProcessNewObjectKeywordsAsync(SearchParams);
else
await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams);
}
public async Task<Search.SearchIndexProcessObjectParameters> GetSearchResultSummary(long id, AyaType specificType)
{
var obj = await GetAsync(id, false);
var SearchParams = new Search.SearchIndexProcessObjectParameters();
DigestSearchText(obj, SearchParams);
return SearchParams;
}
public void DigestSearchText(Customer obj, Search.SearchIndexProcessObjectParameters searchParams)
{
if (obj != null)
searchParams.AddText(obj.Notes)
.AddText(obj.Name)
.AddText(obj.Wiki)
.AddText(obj.Tags)
.AddText(obj.WebAddress)
.AddText(obj.AlertNotes)
.AddText(obj.TechNotes)
.AddText(obj.AccountNumber)
.AddText(obj.Phone1)
.AddText(obj.Phone2)
.AddText(obj.Phone3)
.AddText(obj.Phone4)
.AddText(obj.Phone5)
.AddText(obj.EmailAddress)
.AddText(obj.PostAddress)
.AddText(obj.PostCity)
.AddText(obj.PostRegion)
.AddText(obj.PostCountry)
.AddText(obj.PostCode)
.AddText(obj.Address)
.AddText(obj.City)
.AddText(obj.Region)
.AddText(obj.Country)
.AddText(obj.Latitude)
.AddText(obj.Longitude)
.AddCustomFields(obj.CustomFields);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//VALIDATION
//
private async Task ValidateAsync(Customer proposedObj, Customer currentObj)
{
//skip validation if seeding
if (ServerBootConfig.SEEDING) return;
bool isNew = currentObj == null;
//Name required
if (string.IsNullOrWhiteSpace(proposedObj.Name))
AddError(ApiErrorCode.VALIDATION_REQUIRED, "Name");
//If name is otherwise OK, check that name is unique
if (!PropertyHasErrors("Name"))
{
//Use Any command is efficient way to check existance, it doesn't return the record, just a true or false
if (await ct.Customer.AnyAsync(z => z.Name == proposedObj.Name && z.Id != proposedObj.Id))
{
AddError(ApiErrorCode.VALIDATION_NOT_UNIQUE, "Name");
}
}
if (proposedObj.ContractId != null)
if (proposedObj.ContractExpires == null)
AddError(ApiErrorCode.VALIDATION_REQUIRED, "ContractExpires");
if (proposedObj.BillHeadOffice && (proposedObj.HeadOfficeId == null || proposedObj.HeadOfficeId == 0))
{
AddError(ApiErrorCode.VALIDATION_REQUIRED, "HeadOfficeId");
}
//Any form customizations to validate?
var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.Customer.ToString());
if (FormCustomization != null)
{
//Yeppers, do the validation, there are two, the custom fields and the regular fields that might be set to required
//validate users choices for required non custom fields
RequiredFieldsValidator.Validate(this, FormCustomization, proposedObj);
//validate custom fields
CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields);
}
}
private async Task ValidateCanDeleteAsync(Customer inObj)
{
//## NOTE: contact isn't so important, this could be changed to check only more important things like workorders etc
//and just attempt to delete all the contacts if possible, but for now....
//The plan is anything that is "in" the same form is automatically deleted (unless really critical)
//However, anything that you select on "another" form is not automatically deleted and is allowed to trigger a ref integrity check
//So, for a customer, the Customer Notes collection is accessed from within the Customer form, even though it's actually external but user doesn't see it that way
//so they would be deleted automatically, same goes for Contacts (if they can be deleted and don't have connections elsewhere)
//Workorders and things that you select a Customer for would trigger an error and NOT be deleted automatically
//The Mass delete Extension will be used in those cases to clear out all the workorders etc
//FOREIGN KEY CHECKS
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"));
// await Task.CompletedTask;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//REPORTING
//
public async Task<JArray> GetReportData(DataListSelectedRequest dataListSelectedRequest)
{
var idList = dataListSelectedRequest.SelectedRowIds;
JArray ReportData = new JArray();
while (idList.Any())
{
var batch = idList.Take(IReportAbleObject.REPORT_DATA_BATCH_SIZE);
idList = idList.Skip(IReportAbleObject.REPORT_DATA_BATCH_SIZE).ToArray();
//query for this batch, comes back in db natural order unfortunately
var batchResults = await ct.Customer.AsNoTracking().Where(z => batch.Contains(z.Id)).ToArrayAsync();
//order the results back into original
//What is happening here:
//for performance the query is batching a bunch at once by fetching a block of items from the sql server
//however it's returning in db order which is often not the order the id list is in
//so it needs to be sorted back into the same order as the ide list
//This would not be necessary if just fetching each one at a time individually (like in workorder get report data)
var orderedList = from id in batch join z in batchResults on id equals z.Id select z;
foreach (Customer w in orderedList)
{
await PopulateVizFields(w);
var jo = JObject.FromObject(w);
if (!JsonUtil.JTokenIsNullOrEmpty(jo["CustomFields"]))
jo["CustomFields"] = JObject.Parse((string)jo["CustomFields"]);
ReportData.Add(jo);
}
}
return ReportData;
}
//populate viz fields from provided object
private async Task PopulateVizFields(Customer o)
{
if (o.HeadOfficeId != null)
o.HeadOfficeViz = await ct.HeadOffice.AsNoTracking().Where(x => x.Id == o.HeadOfficeId).Select(x => x.Name).FirstOrDefaultAsync();
if (o.ContractId != null)
o.ContractViz = await ct.Contract.AsNoTracking().Where(x => x.Id == o.ContractId).Select(x => x.Name).FirstOrDefaultAsync();
/*
last completed work order stuff, way to complex to fetch via EF Core so dropping to raw sql
SELECT serial AS LASTWORKORDERSERIAL,
SERVICEDATE AS LASTWORKORDERSERVICEDATE,
AWORKORDER.ID AS LASTWORKORDERID
FROM AWORKORDER
LEFT JOIN AWORKORDERSTATUS ON AWORKORDER.LASTSTATUSID = AWORKORDERSTATUS.ID
WHERE AWORKORDERSTATUS.COMPLETED = TRUE
AND AWORKORDER.CUSTOMERID = ACUSTOMER.ID
ORDER BY AWORKORDER.ID DESC
LIMIT 1
*/
using (var command = ct.Database.GetDbConnection().CreateCommand())
{
await ct.Database.OpenConnectionAsync();
command.CommandText = @$"SELECT serial AS LASTWORKORDERSERIAL,
SERVICEDATE AS LASTWORKORDERSERVICEDATE
FROM AWORKORDER
LEFT JOIN AWORKORDERSTATUS ON AWORKORDER.LASTSTATUSID = AWORKORDERSTATUS.ID
WHERE AWORKORDERSTATUS.COMPLETED = TRUE
AND AWORKORDER.CUSTOMERID = {o.Id}
ORDER BY AWORKORDER.ID DESC
LIMIT 1";
using (var dr = await command.ExecuteReaderAsync())
if (await dr.ReadAsync())
{
o.LastCompletedWorkOrderViz = dr.GetInt64(0);
o.LastCompletedServiceDateViz = dr.GetDateTime(1);
}
await ct.Database.CloseConnectionAsync();
}
}
////////////////////////////////////////////////////////////////////////////////////////////////
// IMPORT EXPORT
//
public async Task<JArray> 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<List<string>> ImportData(JArray ja)
{
List<string> ImportResult = new List<string>();
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<Customer>(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 CreateAsync(w);
if (res == null)
{
ImportResult.Add($"* {w.Name} - {this.GetErrorsAsString()}");
this.ClearErrors();
}
else
{
ImportResult.Add($"{w.Name} - ok");
}
}
return ImportResult;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//JOB / OPERATIONS
//
public async Task HandleJobAsync(OpsJob job)
{
//Hand off the particular job to the corresponding processing code
//NOTE: If this code throws an exception the caller (JobsBiz::ProcessJobsAsync) will automatically set the job to failed and log the exeption so
//basically any error condition during job processing should throw up an exception if it can't be handled
switch (job.JobType)
{
case JobType.BatchCoreObjectOperation:
await ProcessBatchJobAsync(job);
break;
default:
throw new System.ArgumentOutOfRangeException($"CustomerBiz.HandleJob-> Invalid job type{job.JobType.ToString()}");
}
}
private async Task ProcessBatchJobAsync(OpsJob job)
{
await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Running);
await JobsBiz.LogJobAsync(job.GId, $"LT:StartJob {job.SubType}");
List<long> idList = new List<long>();
long FailedObjectCount = 0;
JObject jobData = JObject.Parse(job.JobInfo);
if (jobData.ContainsKey("idList"))
idList = ((JArray)jobData["idList"]).ToObject<List<long>>();
else
idList = await ct.Customer.AsNoTracking().Select(z => z.Id).ToListAsync();
bool SaveIt = false;
foreach (long id in idList)
{
try
{
SaveIt = false;
ClearErrors();
Customer o = null;
//save a fetch if it's a delete
if (job.SubType != JobSubType.Delete)
o = await GetAsync(id, false);
switch (job.SubType)
{
case JobSubType.TagAddAny:
case JobSubType.TagAdd:
case JobSubType.TagRemoveAny:
case JobSubType.TagRemove:
case JobSubType.TagReplaceAny:
case JobSubType.TagReplace:
SaveIt = TagBiz.ProcessBatchTagOperation(o.Tags, (string)jobData["tag"], jobData.ContainsKey("toTag") ? (string)jobData["toTag"] : null, job.SubType);
break;
case JobSubType.Delete:
if (!await DeleteAsync(id))
{
await JobsBiz.LogJobAsync(job.GId, $"LT:Errors {GetErrorsAsString()} id {id}");
FailedObjectCount++;
}
break;
default:
throw new System.ArgumentOutOfRangeException($"ProcessBatchJobAsync -> Invalid job Subtype{job.SubType}");
}
if (SaveIt)
{
o = await PutAsync(o);
if (o == null)
{
await JobsBiz.LogJobAsync(job.GId, $"LT:Errors {GetErrorsAsString()} id {id}");
FailedObjectCount++;
}
}
}
catch (Exception ex)
{
await JobsBiz.LogJobAsync(job.GId, $"LT:Errors id({id})");
await JobsBiz.LogJobAsync(job.GId, ExceptionUtil.ExtractAllExceptionMessages(ex));
}
}
await JobsBiz.LogJobAsync(job.GId, $"LT:BatchJob {job.SubType} {idList.Count}{(FailedObjectCount > 0 ? " - LT:Failed " + FailedObjectCount : "")}");
await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Completed);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// NOTIFICATION PROCESSING
//
public async Task HandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null)
{
ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger<CustomerBiz>();
if (ServerBootConfig.SEEDING || ServerBootConfig.MIGRATING) return;
log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{this.BizType}, AyaEvent:{ayaEvent}]");
bool isNew = currentObj == null;
//STANDARD EVENTS FOR ALL OBJECTS
await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct);
//SPECIFIC EVENTS FOR THIS OBJECT
Customer o = (Customer)proposedObj;
//## 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, AyaType.Customer, o.Id, NotifyEventType.ContractExpiring);
//## CREATED / MODIFIED EVENTS
if (ayaEvent == AyaEvent.Created || ayaEvent == AyaEvent.Modified)
{
//# CONTRACT EXPIRY
{
if (o.ContractId != null && o.ContractExpires != null)
{
var ContractExpirydate = (DateTime)o.ContractExpires;
//notify users about contract expiry (time delayed)
var subs = await ct.NotifySubscription.Where(z => z.EventType == NotifyEventType.ContractExpiring).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(o.Tags, sub.Tags))
{
NotifyEvent n = new NotifyEvent()
{
EventType = NotifyEventType.ContractExpiring,
UserId = sub.UserId,
AyaType = o.AyaType,
ObjectId = o.Id,
NotifySubscriptionId = sub.Id,
Name = o.Name,
EventDate = ContractExpirydate
};
await ct.NotifyEvent.AddAsync(n);
log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]");
await ct.SaveChangesAsync();
}
}
}
}//Contract expiry event
}
}//end of process notifications
/////////////////////////////////////////////////////////////////////
}//eoc
}//eons