191 lines
19 KiB
C#
191 lines
19 KiB
C#
using Disco.Data.Repository;
|
|
using Disco.Models.Repository;
|
|
using Disco.Plugins.ServiceTracker.Models;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
namespace Disco.Plugins.ServiceTracker.Services
|
|
{
|
|
public class ServiceTrackerService
|
|
{
|
|
public const string PluginVersion = "1.2.0";
|
|
private readonly DiscoDataContext _database;
|
|
private readonly ServiceTrackerDataStore _dataStore;
|
|
private readonly ServiceTrackerConfig _config;
|
|
public ServiceTrackerService(DiscoDataContext database, ServiceTrackerDataStore dataStore) { _database = database; _dataStore = dataStore; _config = dataStore.LoadConfig(); }
|
|
public ServiceTrackerConfig Config { get { return _config; } }
|
|
|
|
public DashboardViewModel BuildDashboard(string filterPriority = null, string filterLocation = null, string filterStatus = null, string filterTech = null, string sortBy = "newest")
|
|
{
|
|
var tiles = new List<DashboardTile>(); string sheetError = null;
|
|
var openJobs = _database.Jobs.Include("Device").Include("Device.DeviceModel").Include("User").Include("OpenedTechUser").Include("JobType").Include("JobSubTypes").Where(j => j.ClosedDate == null).ToList();
|
|
var allTickets = _dataStore.LoadAllTickets(); var ticketLookup = allTickets.ToDictionary(t => t.JobId, t => t);
|
|
if (_config.AutoCreateTicketsForNewJobs) { foreach (var job in openJobs) { if (!ticketLookup.ContainsKey(job.Id)) { var nt = CreateDefaultTicket(job); _dataStore.SaveTicket(nt); ticketLookup[job.Id] = nt; } } }
|
|
foreach (var job in openJobs) { ServiceTicket ticket; ticketLookup.TryGetValue(job.Id, out ticket); tiles.Add(BuildDiscoTile(job, ticket)); }
|
|
int sheetCount = 0;
|
|
if (_config.GoogleSheet != null && _config.GoogleSheet.Enabled)
|
|
{
|
|
try
|
|
{
|
|
var sr = new GoogleSheetService(_config.GoogleSheet, _dataStore.DataDirectory).FetchTickets();
|
|
if (sr.Error != null) sheetError = sr.Error;
|
|
else
|
|
{
|
|
if (sr.Warning != null) sheetError = sr.Warning;
|
|
var ext = _dataStore.LoadExternalTickets(); var el = ext.ToDictionary(t => t.JobId, t => t);
|
|
foreach (var e in sr.Tickets)
|
|
{
|
|
ServiceTicket st; if (!el.TryGetValue(e.InternalId, out st))
|
|
{ st = new ServiceTicket { JobId = e.InternalId, Source = "ntt", PriorityId = GoogleSheetService.MapPriority(e.RawPriority), LocationId = _config.DefaultLocationId, AssignedTechId = ResolveSheetTech(e.AssignedTo), Summary = e.IssueDescription, EstimatedCompletion = e.PreferredDate, CreatedDate = e.Timestamp, LastModifiedDate = DateTime.Now }; _dataStore.SaveExternalTicket(st); el[e.InternalId] = st; }
|
|
tiles.Add(BuildSheetTile(e, st)); sheetCount++;
|
|
}
|
|
}
|
|
} catch (Exception ex) { sheetError = "Sheet error: " + ex.Message; }
|
|
}
|
|
var rfr = tiles.Where(t => { var s = (t.StatusOverride ?? t.DiscoStatus ?? "").ToLower(); return s.Contains("ready for return") || s.Contains("ready for pickup"); }).ToList();
|
|
var main = tiles.Where(t => !rfr.Contains(t)).ToList();
|
|
if (!string.IsNullOrEmpty(filterPriority)) main = main.Where(t => t.PriorityId == filterPriority).ToList();
|
|
if (!string.IsNullOrEmpty(filterLocation)) main = main.Where(t => t.LocationId == filterLocation).ToList();
|
|
if (!string.IsNullOrEmpty(filterStatus)) main = main.Where(t => t.StatusOverride == filterStatus).ToList();
|
|
if (!string.IsNullOrEmpty(filterTech)) main = main.Where(t => t.AssignedTechId == filterTech).ToList();
|
|
switch (sortBy)
|
|
{
|
|
case "priority": main = main.OrderBy(t => t.PrioritySortOrder).ThenBy(t => t.SortDate).ToList(); break;
|
|
case "age": main = main.OrderByDescending(t => t.AgeDays).ToList(); break;
|
|
case "modified": main = main.OrderByDescending(t => t.LastModifiedDate).ToList(); break;
|
|
case "sla": main = main.OrderBy(t => t.IsSlaBreached ? 0 : t.IsSlaWarning ? 1 : 2).ThenBy(t => t.SlaDeadline.HasValue ? t.SlaDeadline.Value : DateTime.MaxValue).ToList(); break;
|
|
case "due": main = main.OrderBy(t => t.SortDate).ToList(); break;
|
|
case "newest": default: main = main.OrderByDescending(t => t.OpenedDate).ToList(); break;
|
|
}
|
|
var stats = BuildStats(main); stats.FromGoogleSheet = sheetCount;
|
|
return new DashboardViewModel { Tiles = main, ReadyForReturn = rfr, Stats = stats, Config = _config, CurrentFilter = filterPriority ?? filterLocation ?? filterStatus ?? "", SortBy = sortBy, GoogleSheetError = sheetError };
|
|
}
|
|
|
|
private DashboardTile BuildDiscoTile(Job job, ServiceTicket ticket)
|
|
{
|
|
var now = DateTime.Now;
|
|
var pid = (ticket != null ? ticket.PriorityId : null) ?? _config.DefaultPriorityId;
|
|
var pri = _config.Priorities.FirstOrDefault(p => p.Id == pid) ?? _config.Priorities.FirstOrDefault();
|
|
var lid = (ticket != null ? ticket.LocationId : null) ?? _config.DefaultLocationId;
|
|
var loc = _config.Locations.FirstOrDefault(l => l.Id == lid) ?? _config.Locations.FirstOrDefault();
|
|
var sla = ticket != null ? ticket.SlaDeadline : (DateTime?)null;
|
|
if (!sla.HasValue && pri != null && pri.SlaHours > 0) sla = job.OpenedDate.AddHours(pri.SlaHours);
|
|
bool breach = sla.HasValue && now > sla.Value;
|
|
bool warn = !breach && sla.HasValue && pri != null && pri.SlaHours > 0 && now > sla.Value.AddHours(-pri.SlaHours * 0.25);
|
|
int age = (int)(now - job.OpenedDate).TotalDays;
|
|
var eta = (ticket != null ? ticket.EstimatedCompletion : null) ?? job.ExpectedClosedDate;
|
|
string ds = "Open";
|
|
if (job.WaitingForUserAction.HasValue) ds = "Awaiting User Action";
|
|
else if (job.DeviceHeld.HasValue && !job.DeviceReadyForReturn.HasValue) ds = "Device Held";
|
|
else if (job.DeviceReadyForReturn.HasValue && !job.DeviceReturnedDate.HasValue) ds = "Ready for Return";
|
|
string ln = null; int nc = 0;
|
|
if (ticket != null && ticket.Notes != null && ticket.Notes.Count > 0) { nc = ticket.Notes.Count; ln = ticket.Notes.OrderByDescending(n => n.Timestamp).First().Content; if (ln != null && ln.Length > 80) ln = ln.Substring(0, 77) + "..."; }
|
|
var tid = ticket != null ? ticket.AssignedTechId : job.OpenedTechUserId;
|
|
return new DashboardTile
|
|
{
|
|
JobId = job.Id, Source = "disco", DisplayId = "DIC#" + job.Id,
|
|
JobTypeDescription = job.JobType != null ? job.JobType.Description : job.JobTypeId,
|
|
DeviceSerialNumber = job.DeviceSerialNumber ?? "\u2014",
|
|
DeviceModelDescription = job.Device != null && job.Device.DeviceModel != null ? job.Device.DeviceModel.Description : null,
|
|
DeviceComputerName = job.Device != null ? job.Device.DeviceDomainId : null,
|
|
UserId = job.UserId, UserDisplayName = job.User != null ? job.User.DisplayName : job.UserId,
|
|
OpenedByTechId = job.OpenedTechUserId, OpenedByTechName = job.OpenedTechUser != null ? job.OpenedTechUser.DisplayName : job.OpenedTechUserId,
|
|
OpenedDate = job.OpenedDate, ExpectedClosedDate = job.ExpectedClosedDate, DiscoStatus = ds,
|
|
PriorityId = pid, PriorityName = pri != null ? pri.Name : "Unknown", PriorityColor = pri != null ? pri.Color : "#999", PrioritySortOrder = pri != null ? pri.SortOrder : 99,
|
|
LocationId = lid, LocationName = loc != null ? loc.Name : "Unknown", LocationIcon = loc != null ? loc.Icon : "", LocationColor = loc != null ? loc.Color : "#999",
|
|
AssignedTechId = tid, AssignedTechName = ResolveTechName(tid), EstimatedCompletion = eta, SlaDeadline = sla,
|
|
StatusOverride = ticket != null && ticket.StatusOverride != null ? ticket.StatusOverride : ds,
|
|
Summary = ticket != null ? ticket.Summary : null, NoteCount = nc, LatestNote = ln,
|
|
LastModifiedDate = ticket != null ? ticket.LastModifiedDate : job.OpenedDate,
|
|
IsSlaBreached = breach, IsSlaWarning = warn, AgeBadge = FmtAge(age), AgeDays = age, EtaDisplay = FmtEta(eta),
|
|
SortDate = breach && sla.HasValue ? sla.Value : eta.HasValue ? eta.Value : job.ExpectedClosedDate.HasValue ? job.ExpectedClosedDate.Value : job.OpenedDate
|
|
};
|
|
}
|
|
|
|
private DashboardTile BuildSheetTile(ExternalTicket ext, ServiceTicket st)
|
|
{
|
|
var now = DateTime.Now;
|
|
var pid = st != null ? st.PriorityId : GoogleSheetService.MapPriority(ext.RawPriority);
|
|
var pri = _config.Priorities.FirstOrDefault(p => p.Id == pid) ?? _config.Priorities.FirstOrDefault();
|
|
var lid = st != null ? st.LocationId : _config.DefaultLocationId;
|
|
var loc = _config.Locations.FirstOrDefault(l => l.Id == lid) ?? _config.Locations.FirstOrDefault();
|
|
int age = (int)(now - ext.Timestamp).TotalDays;
|
|
var tid = st != null ? st.AssignedTechId : ResolveSheetTech(ext.AssignedTo);
|
|
string ln = null; int nc = 0;
|
|
if (st != null && st.Notes != null && st.Notes.Count > 0) { nc = st.Notes.Count; ln = st.Notes.OrderByDescending(n => n.Timestamp).First().Content; if (ln != null && ln.Length > 80) ln = ln.Substring(0, 77) + "..."; }
|
|
var rn = ext.RequesterName ?? ext.RequesterEmail ?? "Unknown";
|
|
if (string.IsNullOrEmpty(ext.RequesterName) && rn.Contains("@")) rn = rn.Split('@')[0].Replace(".", " ");
|
|
return new DashboardTile
|
|
{
|
|
JobId = ext.InternalId, Source = "ntt", DisplayId = "NTT#" + ext.ExternalId.Replace("NTT", ""),
|
|
JobTypeDescription = "NTT Sheet", DeviceSerialNumber = ext.TaskTitle ?? "\u2014", DeviceComputerName = ext.TaskTitle,
|
|
UserId = ext.RequesterEmail, UserDisplayName = rn, OpenedDate = ext.Timestamp, DiscoStatus = ext.RawStatus ?? "Open",
|
|
PriorityId = pid, PriorityName = pri != null ? pri.Name : "Unknown", PriorityColor = pri != null ? pri.Color : "#999", PrioritySortOrder = pri != null ? pri.SortOrder : 99,
|
|
LocationId = lid, LocationName = loc != null ? loc.Name : "Unknown", LocationIcon = loc != null ? loc.Icon : "", LocationColor = loc != null ? loc.Color : "#999",
|
|
AssignedTechId = tid, AssignedTechName = ResolveTechName(tid),
|
|
StatusOverride = st != null && st.StatusOverride != null ? st.StatusOverride : (ext.RawStatus ?? "Open"),
|
|
Summary = ext.IssueDescription, EstimatedCompletion = ext.PreferredDate, NoteCount = nc, LatestNote = ln,
|
|
LastModifiedDate = st != null ? st.LastModifiedDate : ext.Timestamp,
|
|
AgeBadge = FmtAge(age), AgeDays = age,
|
|
EtaDisplay = FmtEta(st != null && st.EstimatedCompletion.HasValue ? st.EstimatedCompletion : ext.PreferredDate),
|
|
SortDate = st != null && st.EstimatedCompletion.HasValue ? st.EstimatedCompletion.Value : ext.PreferredDate.HasValue ? ext.PreferredDate.Value : ext.Timestamp
|
|
};
|
|
}
|
|
|
|
public string ResolveTechName(string id) { if (string.IsNullOrEmpty(id)) return null; foreach (var t in _config.Technicians) { if (t.Id == id || t.DisplayName == id) return t.DisplayName; if (!string.IsNullOrEmpty(t.Email) && t.Email.Equals(id, StringComparison.OrdinalIgnoreCase)) return t.DisplayName; if (t.DiscoUserIds != null) foreach (var d in t.DiscoUserIds) if (d.Equals(id, StringComparison.OrdinalIgnoreCase)) return t.DisplayName; } try { var u = _database.Users.FirstOrDefault(u2 => u2.UserId == id); if (u != null) return u.DisplayName; } catch { } return id; }
|
|
private string ResolveSheetTech(string a) { if (string.IsNullOrEmpty(a)) return null; foreach (var t in _config.Technicians) { if (!string.IsNullOrEmpty(t.Email) && t.Email.Equals(a, StringComparison.OrdinalIgnoreCase)) return t.Id; if (t.DisplayName.Equals(a, StringComparison.OrdinalIgnoreCase)) return t.Id; } return a; }
|
|
|
|
public void UpdateTicket(int jobId, string source, string priorityId, string locationId, string assignedTechId, DateTime? eta, string status, string summary, string modifiedBy)
|
|
{
|
|
ServiceTicket ticket = source == "ntt" ? _dataStore.GetExternalTicket(jobId) : _dataStore.GetTicket(jobId);
|
|
if (ticket == null) ticket = new ServiceTicket { JobId = jobId, Source = source ?? "disco" };
|
|
if (ticket.ChangeLog == null) ticket.ChangeLog = new List<ChangeEntry>();
|
|
if (priorityId != null && priorityId != ticket.PriorityId) { ticket.ChangeLog.Add(new ChangeEntry { UserId = modifiedBy, Field = "Priority", OldValue = ticket.PriorityId, NewValue = priorityId }); ticket.PriorityId = priorityId; }
|
|
if (locationId != null && locationId != ticket.LocationId) { ticket.ChangeLog.Add(new ChangeEntry { UserId = modifiedBy, Field = "Location", OldValue = ticket.LocationId, NewValue = locationId }); ticket.LocationId = locationId; }
|
|
if (assignedTechId != null && assignedTechId != ticket.AssignedTechId) { ticket.ChangeLog.Add(new ChangeEntry { UserId = modifiedBy, Field = "Tech", OldValue = ticket.AssignedTechId, NewValue = assignedTechId }); ticket.AssignedTechId = assignedTechId; }
|
|
if (status != null && status != ticket.StatusOverride) { ticket.ChangeLog.Add(new ChangeEntry { UserId = modifiedBy, Field = "Status", OldValue = ticket.StatusOverride ?? "(default)", NewValue = string.IsNullOrEmpty(status) ? "(default)" : status }); ticket.StatusOverride = status; }
|
|
if (summary != null && summary != ticket.Summary) { ticket.ChangeLog.Add(new ChangeEntry { UserId = modifiedBy, Field = "Summary", OldValue = null, NewValue = "(updated)" }); ticket.Summary = summary; }
|
|
if (eta.HasValue) { var o = ticket.EstimatedCompletion.HasValue ? ticket.EstimatedCompletion.Value.ToString("dd MMM yyyy") : "none"; var n = eta.Value.ToString("dd MMM yyyy"); if (o != n) { ticket.ChangeLog.Add(new ChangeEntry { UserId = modifiedBy, Field = "ETA", OldValue = o, NewValue = n }); ticket.EstimatedCompletion = eta; } }
|
|
ticket.LastModifiedBy = modifiedBy;
|
|
if (priorityId != null) { var p = _config.Priorities.FirstOrDefault(x => x.Id == priorityId); ticket.SlaDeadline = (p != null && p.SlaHours > 0) ? ticket.CreatedDate.AddHours(p.SlaHours) : (DateTime?)null; }
|
|
if (source == "ntt") _dataStore.SaveExternalTicket(ticket); else _dataStore.SaveTicket(ticket);
|
|
}
|
|
|
|
public void AddNote(int jobId, string source, string authorId, string authorName, string content, string noteType)
|
|
{
|
|
var note = new TicketNote { AuthorId = authorId, AuthorName = authorName, Content = content, NoteType = noteType ?? "general" };
|
|
if (source == "ntt") _dataStore.AddExternalNote(jobId, note);
|
|
else { _dataStore.AddNote(jobId, note); try { var job = _database.Jobs.FirstOrDefault(j => j.Id == jobId); if (job != null) { _database.JobLogs.Add(new JobLog { JobId = jobId, TechUserId = authorId, Timestamp = DateTime.Now, Comments = "[Service Tracker] " + content }); _database.SaveChanges(); } } catch { } }
|
|
}
|
|
|
|
public void MarkCollected(int jobId, string source, string userId) { UpdateTicket(jobId, source, null, null, null, null, "Resolved", null, userId); }
|
|
public ServiceTicket GetTicketDetail(int jobId, string source) { return source == "ntt" ? _dataStore.GetExternalTicket(jobId) : _dataStore.GetTicket(jobId); }
|
|
private string FmtAge(int d) { if (d == 0) return "Today"; if (d == 1) return "1 day"; if (d < 7) return d + " days"; if (d < 30) return (d/7) + " wk" + (d/7>1?"s":""); return (d/30) + " mo" + (d/30>1?"s":""); }
|
|
private string FmtEta(DateTime? e) { if (!e.HasValue) return "\u2014"; var d=(int)(e.Value-DateTime.Now).TotalDays; if(d<0) return Math.Abs(d)+"d overdue"; if(d==0) return "Today"; if(d==1) return "Tomorrow"; return e.Value.ToString("dd MMM"); }
|
|
|
|
private DashboardStats BuildStats(List<DashboardTile> tiles)
|
|
{
|
|
var s = new DashboardStats { TotalOpen = tiles.Count, SlaBreached = tiles.Count(t => t.IsSlaBreached), SlaWarning = tiles.Count(t => t.IsSlaWarning), AvgAgeDays = tiles.Count > 0 ? Math.Round(tiles.Average(t => (double)t.AgeDays), 1) : 0, OldestJobDays = tiles.Count > 0 ? tiles.Max(t => t.AgeDays) : 0 };
|
|
foreach (var p in _config.Priorities) s.ByPriority[p.Id] = tiles.Count(t => t.PriorityId == p.Id);
|
|
s.Critical = tiles.Count(t => t.PriorityId == "critical"); s.High = tiles.Count(t => t.PriorityId == "high"); s.Medium = tiles.Count(t => t.PriorityId == "medium"); s.Low = tiles.Count(t => t.PriorityId == "low"); s.Scheduled = tiles.Count(t => t.PriorityId == "scheduled");
|
|
foreach (var l in _config.Locations) s.ByLocation[l.Id] = tiles.Count(t => t.LocationId == l.Id);
|
|
s.InItOffice = tiles.Count(t => t.LocationId == "it-office"); s.WithUser = tiles.Count(t => t.LocationId == "with-user"); s.AtRepairer = tiles.Count(t => t.LocationId == "at-repairer");
|
|
foreach (var t in tiles) { var st = t.StatusOverride ?? "Open"; if (!s.ByStatus.ContainsKey(st)) s.ByStatus[st] = 0; s.ByStatus[st]++; }
|
|
foreach (var t in tiles.Where(t2 => !string.IsNullOrEmpty(t2.AssignedTechId))) { var tn = t.AssignedTechName ?? t.AssignedTechId; if (!s.ByTech.ContainsKey(tn)) s.ByTech[tn] = 0; s.ByTech[tn]++; }
|
|
return s;
|
|
}
|
|
|
|
private ServiceTicket CreateDefaultTicket(Job job)
|
|
{
|
|
var p = _config.Priorities.FirstOrDefault(x => x.Id == _config.DefaultPriorityId);
|
|
DateTime? sla = (p != null && p.SlaHours > 0) ? job.OpenedDate.AddHours(p.SlaHours) : (DateTime?)null;
|
|
string lid = _config.DefaultLocationId;
|
|
if (job.DeviceHeld.HasValue && !string.IsNullOrEmpty(job.DeviceHeldLocation)) { var ml = _config.Locations.FirstOrDefault(l => l.Name.Equals(job.DeviceHeldLocation, StringComparison.OrdinalIgnoreCase)); if (ml != null) lid = ml.Id; }
|
|
var tid = ResolveDiscoTechToId(job.OpenedTechUserId);
|
|
return new ServiceTicket { JobId = job.Id, Source = "disco", PriorityId = _config.DefaultPriorityId, LocationId = lid, AssignedTechId = tid, EstimatedCompletion = job.ExpectedClosedDate, SlaDeadline = sla, CreatedDate = DateTime.Now, LastModifiedDate = DateTime.Now };
|
|
}
|
|
private string ResolveDiscoTechToId(string uid) { if (string.IsNullOrEmpty(uid)) return null; foreach (var t in _config.Technicians) if (t.DiscoUserIds != null) foreach (var d in t.DiscoUserIds) if (d.Equals(uid, StringComparison.OrdinalIgnoreCase)) return t.Id; return uid; }
|
|
}
|
|
}
|